From af8b9fe00fe2c4e744e9f2b8c09e97e305222b65 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 5 Apr 2026 22:34:03 +0300 Subject: [PATCH] feat: complete house plan maker application Full-featured house/apartment floor plan editor with: - Turborepo monorepo (React/Vite client, Fastify/Prisma server, shared Zod schemas) - 2D room editor with walls, doors, windows, furniture, electrical elements - 3D room preview with Three.js (auto-hide nearest walls, bird's eye default) - Wall projection views with interactive drag (elevation, position) - Apartment floor plan view with room positioning - Copy/paste, alignment tools, measurement tool, annotations - Item-attached annotations with leader lines (visible on projections) - Door open direction (LEFT/RIGHT/INWARD/OUTWARD) with swing arc - Floor type textures (wood, tile, concrete, laminate, herringbone) - Wall color picker for 3D view - Furniture: bed, desk, wardrobe, sofa, table, chair, shelf, nightstand, dresser, bookcase, TV (with stand toggle), AC unit - Furniture elevation support (wall-mounted items) - Auto-save with dirty state tracking, batch save API - Rotation-aware collision detection (SAT/OBB) with 3D elevation check - Rotation-aware hit testing - i18n (English/Russian) with locale-aware number formatting - Dark mode with system preference detection - Undo/redo, keyboard shortcuts, scale bar - PDF/PNG/JSON export and JSON import - Focus trap modal, toast notifications, tooltips - Responsive layout with overlay palettes --- .gitignore | 35 + .prettierrc | 8 + apps/client/index.html | 12 + apps/client/package.json | 38 + .../client/public/locales/en/translation.json | 292 + .../client/public/locales/ru/translation.json | 298 + apps/client/src/api/client.ts | 340 + .../components/apartments/ApartmentCard.tsx | 72 + .../apartments/ApartmentFormModal.tsx | 134 + .../apartments/apartment-card.module.css | 40 + .../apartment-form-modal.module.css | 5 + .../src/components/editor/EditorCanvas.tsx | 621 + .../src/components/editor/EditorToolbar.tsx | 253 + .../src/components/editor/PropertiesPanel.tsx | 664 + .../components/editor/RoomEditorLayout.tsx | 580 + .../editor/context/EditorContext.tsx | 879 ++ .../editor/context/UndoRedoContext.tsx | 106 + .../editor/editor-toolbar.module.css | 167 + .../components/editor/export/ExportDialog.tsx | 313 + .../export/__tests__/exportUtils.test.ts | 28 + .../editor/export/export-dialog.module.css | 121 + .../components/editor/export/exportUtils.ts | 246 + .../components/editor/export/roomFormat.ts | 405 + .../components/editor/hooks/useEditorZoom.ts | 170 + .../editor/hooks/useKeyboardShortcuts.ts | 129 + .../components/editor/hooks/useSnapping.ts | 79 + .../editor/layers/AnnotationLayer.tsx | 148 + .../editor/layers/ElectricalLayer.tsx | 170 + .../editor/layers/FurnitureLayer.tsx | 175 + .../components/editor/layers/GridLayer.tsx | 232 + .../editor/layers/MeasureOverlayLayer.tsx | 74 + .../editor/layers/MeasurementLayer.tsx | 300 + .../components/editor/layers/OpeningLayer.tsx | 357 + .../editor/layers/RoomLabelLayer.tsx | 97 + .../editor/layers/SelectionLayer.tsx | 115 + .../components/editor/layers/WallLayer.tsx | 236 + .../components/editor/overlays/ScaleBar.tsx | 60 + .../editor/overlays/scale-bar.module.css | 41 + .../editor/panels/CableLengthStatus.tsx | 32 + .../editor/panels/ElectricalPalette.tsx | 84 + .../editor/panels/FurniturePalette.tsx | 65 + .../panels/cable-length-status.module.css | 20 + .../panels/electrical-palette.module.css | 86 + .../panels/furniture-palette.module.css | 87 + .../editor/projection/ProjectionDoor.tsx | 146 + .../projection/ProjectionElectrical.tsx | 172 + .../editor/projection/ProjectionFurniture.tsx | 65 + .../projection/ProjectionMeasurements.tsx | 241 + .../editor/projection/ProjectionPanel.tsx | 299 + .../editor/projection/ProjectionWindow.tsx | 130 + .../editor/projection/WallProjectionView.tsx | 717 ++ .../projection/projection-panel.module.css | 159 + .../editor/properties-panel.module.css | 133 + .../editor/room-editor-layout.module.css | 102 + .../symbols/electrical/CableRouteSymbol.tsx | 23 + .../symbols/electrical/CeilingLightSymbol.tsx | 23 + .../symbols/electrical/JunctionBoxSymbol.tsx | 33 + .../symbols/electrical/OutletSymbol.tsx | 77 + .../symbols/electrical/SwitchSymbol.tsx | 105 + .../symbols/electrical/WallLightSymbol.tsx | 32 + .../editor/symbols/electrical/index.ts | 42 + .../symbols/furniture/BedSilhouette.tsx | 81 + .../symbols/furniture/ChairSilhouette.tsx | 47 + .../symbols/furniture/DeskSilhouette.tsx | 48 + .../symbols/furniture/ShelfSilhouette.tsx | 48 + .../symbols/furniture/SofaSilhouette.tsx | 65 + .../symbols/furniture/TableSilhouette.tsx | 40 + .../editor/symbols/furniture/TvSilhouette.tsx | 47 + .../symbols/furniture/WardrobeSilhouette.tsx | 54 + .../editor/symbols/furniture/index.ts | 46 + .../editor/templates/TemplatePicker.tsx | 110 + .../templates/__tests__/roomTemplates.test.ts | 67 + .../editor/templates/roomTemplates.ts | 118 + .../templates/template-picker.module.css | 121 + .../editor/three/CameraControls.tsx | 161 + .../components/editor/three/DoorOpening.tsx | 87 + .../editor/three/ElectricalMesh.tsx | 214 + .../components/editor/three/FloorCeiling.tsx | 195 + .../components/editor/three/FurnitureMesh.tsx | 341 + .../components/editor/three/PlinthMesh.tsx | 99 + .../components/editor/three/Room3DView.tsx | 259 + .../components/editor/three/RoomLabels.tsx | 80 + .../src/components/editor/three/WallMesh.tsx | 105 + .../components/editor/three/WindowOpening.tsx | 86 + .../editor/three/utils/wallGeometry.ts | 170 + .../src/components/editor/tools/DoorTool.ts | 77 + .../components/editor/tools/ElectricalTool.ts | 131 + .../components/editor/tools/FurnitureTool.ts | 73 + .../components/editor/tools/MeasureTool.ts | 33 + .../src/components/editor/tools/SelectTool.ts | 240 + .../src/components/editor/tools/WindowTool.ts | 81 + apps/client/src/components/editor/types.ts | 186 + .../__tests__/collisionDetection.test.ts | 72 + .../editor/utils/__tests__/geometry.test.ts | 242 + .../editor/utils/__tests__/wallUtils.test.ts | 174 + .../editor/utils/collisionDetection.ts | 105 + .../src/components/editor/utils/geometry.ts | 196 + .../components/editor/utils/lightCoverage.ts | 13 + .../components/editor/utils/openingUtils.ts | 29 + .../editor/utils/projectionMapping.ts | 354 + .../src/components/editor/utils/wallUtils.ts | 191 + .../client/src/components/layout/AppShell.tsx | 141 + .../components/layout/app-shell.module.css | 241 + apps/client/src/components/rooms/RoomCard.tsx | 119 + .../src/components/rooms/RoomFormModal.tsx | 333 + .../src/components/rooms/room-card.module.css | 60 + .../rooms/room-form-modal.module.css | 84 + .../src/components/shared/ConfirmDialog.tsx | 45 + apps/client/src/components/ui/Button.tsx | 33 + apps/client/src/components/ui/Card.tsx | 81 + apps/client/src/components/ui/EmptyState.tsx | 20 + apps/client/src/components/ui/ErrorBanner.tsx | 28 + apps/client/src/components/ui/Input.tsx | 45 + .../src/components/ui/LoadingSpinner.tsx | 23 + apps/client/src/components/ui/Modal.tsx | 136 + apps/client/src/components/ui/Toast.tsx | 70 + apps/client/src/components/ui/Tooltip.tsx | 86 + .../src/components/ui/button.module.css | 91 + apps/client/src/components/ui/card.module.css | 38 + .../src/components/ui/empty-state.module.css | 29 + .../src/components/ui/error-banner.module.css | 42 + .../client/src/components/ui/input.module.css | 61 + .../components/ui/loading-spinner.module.css | 31 + .../client/src/components/ui/modal.module.css | 86 + .../client/src/components/ui/toast.module.css | 93 + .../src/components/ui/tooltip.module.css | 56 + apps/client/src/contexts/ThemeContext.tsx | 67 + apps/client/src/contexts/ToastContext.tsx | 48 + apps/client/src/i18n.ts | 38 + apps/client/src/main.tsx | 25 + apps/client/src/pages/ApartmentDetailPage.tsx | 300 + .../src/pages/ApartmentFloorPlanPage.tsx | 443 + apps/client/src/pages/ApartmentListPage.tsx | 162 + apps/client/src/pages/RoomEditorPage.tsx | 52 + .../pages/apartment-detail-page.module.css | 65 + .../apartment-floor-plan-page.module.css | 73 + .../src/pages/apartment-list-page.module.css | 36 + .../src/pages/room-editor-page.module.css | 8 + apps/client/src/router.tsx | 40 + apps/client/src/styles/global.css | 108 + apps/client/src/styles/tokens.css | 193 + apps/client/src/utils/format.ts | 9 + apps/client/src/vite-env.d.ts | 1 + apps/client/tsconfig.json | 12 + apps/client/vite.config.ts | 16 + apps/client/vitest.config.ts | 8 + apps/server/.env.example | 6 + apps/server/package.json | 26 + .../migration.sql | 102 + .../prisma/migrations/migration_lock.toml | 3 + apps/server/prisma/schema.prisma | 108 + apps/server/src/index.ts | 42 + apps/server/src/plugins/error-handler.ts | 49 + apps/server/src/plugins/prisma.ts | 23 + apps/server/src/routes/apartments.ts | 140 + apps/server/src/routes/elements.ts | 564 + apps/server/src/routes/rooms.ts | 222 + apps/server/src/utils/mappers.ts | 136 + apps/server/tsconfig.json | 11 + apps/server/vitest.config.ts | 7 + eslint.config.js | 49 + package-lock.json | 10690 ++++++++++++++++ package.json | 27 + packages/shared/package-lock.json | 2343 ++++ packages/shared/package.json | 27 + packages/shared/src/__tests__/schemas.test.ts | 104 + packages/shared/src/index.ts | 103 + .../shared/src/schemas/apartment.schema.ts | 16 + .../shared/src/schemas/elements.schema.ts | 148 + packages/shared/src/schemas/room.schema.ts | 42 + packages/shared/src/types/apartment.ts | 32 + packages/shared/src/types/api.ts | 15 + packages/shared/src/types/elements.ts | 198 + packages/shared/src/types/room.ts | 74 + packages/shared/tsconfig.json | 8 + packages/shared/vitest.config.ts | 7 + plans/house-plan-maker/CONTEXT.md | 50 + plans/house-plan-maker/PLAN.md | 71 + plans/house-plan-maker/phase-1-scaffold.md | 82 + .../phase-2-data-model-api.md | 138 + .../house-plan-maker/phase-3-management-ui.md | 71 + .../phase-4-2d-editor-core.md | 116 + .../phase-5-electrical-furniture.md | 87 + .../phase-6-wall-projections.md | 75 + plans/house-plan-maker/phase-7-3d-view.md | 99 + .../house-plan-maker/phase-8-export-polish.md | 66 + tsconfig.json | 20 + turbo.json | 23 + 188 files changed, 35795 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 apps/client/index.html create mode 100644 apps/client/package.json create mode 100644 apps/client/public/locales/en/translation.json create mode 100644 apps/client/public/locales/ru/translation.json create mode 100644 apps/client/src/api/client.ts create mode 100644 apps/client/src/components/apartments/ApartmentCard.tsx create mode 100644 apps/client/src/components/apartments/ApartmentFormModal.tsx create mode 100644 apps/client/src/components/apartments/apartment-card.module.css create mode 100644 apps/client/src/components/apartments/apartment-form-modal.module.css create mode 100644 apps/client/src/components/editor/EditorCanvas.tsx create mode 100644 apps/client/src/components/editor/EditorToolbar.tsx create mode 100644 apps/client/src/components/editor/PropertiesPanel.tsx create mode 100644 apps/client/src/components/editor/RoomEditorLayout.tsx create mode 100644 apps/client/src/components/editor/context/EditorContext.tsx create mode 100644 apps/client/src/components/editor/context/UndoRedoContext.tsx create mode 100644 apps/client/src/components/editor/editor-toolbar.module.css create mode 100644 apps/client/src/components/editor/export/ExportDialog.tsx create mode 100644 apps/client/src/components/editor/export/__tests__/exportUtils.test.ts create mode 100644 apps/client/src/components/editor/export/export-dialog.module.css create mode 100644 apps/client/src/components/editor/export/exportUtils.ts create mode 100644 apps/client/src/components/editor/export/roomFormat.ts create mode 100644 apps/client/src/components/editor/hooks/useEditorZoom.ts create mode 100644 apps/client/src/components/editor/hooks/useKeyboardShortcuts.ts create mode 100644 apps/client/src/components/editor/hooks/useSnapping.ts create mode 100644 apps/client/src/components/editor/layers/AnnotationLayer.tsx create mode 100644 apps/client/src/components/editor/layers/ElectricalLayer.tsx create mode 100644 apps/client/src/components/editor/layers/FurnitureLayer.tsx create mode 100644 apps/client/src/components/editor/layers/GridLayer.tsx create mode 100644 apps/client/src/components/editor/layers/MeasureOverlayLayer.tsx create mode 100644 apps/client/src/components/editor/layers/MeasurementLayer.tsx create mode 100644 apps/client/src/components/editor/layers/OpeningLayer.tsx create mode 100644 apps/client/src/components/editor/layers/RoomLabelLayer.tsx create mode 100644 apps/client/src/components/editor/layers/SelectionLayer.tsx create mode 100644 apps/client/src/components/editor/layers/WallLayer.tsx create mode 100644 apps/client/src/components/editor/overlays/ScaleBar.tsx create mode 100644 apps/client/src/components/editor/overlays/scale-bar.module.css create mode 100644 apps/client/src/components/editor/panels/CableLengthStatus.tsx create mode 100644 apps/client/src/components/editor/panels/ElectricalPalette.tsx create mode 100644 apps/client/src/components/editor/panels/FurniturePalette.tsx create mode 100644 apps/client/src/components/editor/panels/cable-length-status.module.css create mode 100644 apps/client/src/components/editor/panels/electrical-palette.module.css create mode 100644 apps/client/src/components/editor/panels/furniture-palette.module.css create mode 100644 apps/client/src/components/editor/projection/ProjectionDoor.tsx create mode 100644 apps/client/src/components/editor/projection/ProjectionElectrical.tsx create mode 100644 apps/client/src/components/editor/projection/ProjectionFurniture.tsx create mode 100644 apps/client/src/components/editor/projection/ProjectionMeasurements.tsx create mode 100644 apps/client/src/components/editor/projection/ProjectionPanel.tsx create mode 100644 apps/client/src/components/editor/projection/ProjectionWindow.tsx create mode 100644 apps/client/src/components/editor/projection/WallProjectionView.tsx create mode 100644 apps/client/src/components/editor/projection/projection-panel.module.css create mode 100644 apps/client/src/components/editor/properties-panel.module.css create mode 100644 apps/client/src/components/editor/room-editor-layout.module.css create mode 100644 apps/client/src/components/editor/symbols/electrical/CableRouteSymbol.tsx create mode 100644 apps/client/src/components/editor/symbols/electrical/CeilingLightSymbol.tsx create mode 100644 apps/client/src/components/editor/symbols/electrical/JunctionBoxSymbol.tsx create mode 100644 apps/client/src/components/editor/symbols/electrical/OutletSymbol.tsx create mode 100644 apps/client/src/components/editor/symbols/electrical/SwitchSymbol.tsx create mode 100644 apps/client/src/components/editor/symbols/electrical/WallLightSymbol.tsx create mode 100644 apps/client/src/components/editor/symbols/electrical/index.ts create mode 100644 apps/client/src/components/editor/symbols/furniture/BedSilhouette.tsx create mode 100644 apps/client/src/components/editor/symbols/furniture/ChairSilhouette.tsx create mode 100644 apps/client/src/components/editor/symbols/furniture/DeskSilhouette.tsx create mode 100644 apps/client/src/components/editor/symbols/furniture/ShelfSilhouette.tsx create mode 100644 apps/client/src/components/editor/symbols/furniture/SofaSilhouette.tsx create mode 100644 apps/client/src/components/editor/symbols/furniture/TableSilhouette.tsx create mode 100644 apps/client/src/components/editor/symbols/furniture/TvSilhouette.tsx create mode 100644 apps/client/src/components/editor/symbols/furniture/WardrobeSilhouette.tsx create mode 100644 apps/client/src/components/editor/symbols/furniture/index.ts create mode 100644 apps/client/src/components/editor/templates/TemplatePicker.tsx create mode 100644 apps/client/src/components/editor/templates/__tests__/roomTemplates.test.ts create mode 100644 apps/client/src/components/editor/templates/roomTemplates.ts create mode 100644 apps/client/src/components/editor/templates/template-picker.module.css create mode 100644 apps/client/src/components/editor/three/CameraControls.tsx create mode 100644 apps/client/src/components/editor/three/DoorOpening.tsx create mode 100644 apps/client/src/components/editor/three/ElectricalMesh.tsx create mode 100644 apps/client/src/components/editor/three/FloorCeiling.tsx create mode 100644 apps/client/src/components/editor/three/FurnitureMesh.tsx create mode 100644 apps/client/src/components/editor/three/PlinthMesh.tsx create mode 100644 apps/client/src/components/editor/three/Room3DView.tsx create mode 100644 apps/client/src/components/editor/three/RoomLabels.tsx create mode 100644 apps/client/src/components/editor/three/WallMesh.tsx create mode 100644 apps/client/src/components/editor/three/WindowOpening.tsx create mode 100644 apps/client/src/components/editor/three/utils/wallGeometry.ts create mode 100644 apps/client/src/components/editor/tools/DoorTool.ts create mode 100644 apps/client/src/components/editor/tools/ElectricalTool.ts create mode 100644 apps/client/src/components/editor/tools/FurnitureTool.ts create mode 100644 apps/client/src/components/editor/tools/MeasureTool.ts create mode 100644 apps/client/src/components/editor/tools/SelectTool.ts create mode 100644 apps/client/src/components/editor/tools/WindowTool.ts create mode 100644 apps/client/src/components/editor/types.ts create mode 100644 apps/client/src/components/editor/utils/__tests__/collisionDetection.test.ts create mode 100644 apps/client/src/components/editor/utils/__tests__/geometry.test.ts create mode 100644 apps/client/src/components/editor/utils/__tests__/wallUtils.test.ts create mode 100644 apps/client/src/components/editor/utils/collisionDetection.ts create mode 100644 apps/client/src/components/editor/utils/geometry.ts create mode 100644 apps/client/src/components/editor/utils/lightCoverage.ts create mode 100644 apps/client/src/components/editor/utils/openingUtils.ts create mode 100644 apps/client/src/components/editor/utils/projectionMapping.ts create mode 100644 apps/client/src/components/editor/utils/wallUtils.ts create mode 100644 apps/client/src/components/layout/AppShell.tsx create mode 100644 apps/client/src/components/layout/app-shell.module.css create mode 100644 apps/client/src/components/rooms/RoomCard.tsx create mode 100644 apps/client/src/components/rooms/RoomFormModal.tsx create mode 100644 apps/client/src/components/rooms/room-card.module.css create mode 100644 apps/client/src/components/rooms/room-form-modal.module.css create mode 100644 apps/client/src/components/shared/ConfirmDialog.tsx create mode 100644 apps/client/src/components/ui/Button.tsx create mode 100644 apps/client/src/components/ui/Card.tsx create mode 100644 apps/client/src/components/ui/EmptyState.tsx create mode 100644 apps/client/src/components/ui/ErrorBanner.tsx create mode 100644 apps/client/src/components/ui/Input.tsx create mode 100644 apps/client/src/components/ui/LoadingSpinner.tsx create mode 100644 apps/client/src/components/ui/Modal.tsx create mode 100644 apps/client/src/components/ui/Toast.tsx create mode 100644 apps/client/src/components/ui/Tooltip.tsx create mode 100644 apps/client/src/components/ui/button.module.css create mode 100644 apps/client/src/components/ui/card.module.css create mode 100644 apps/client/src/components/ui/empty-state.module.css create mode 100644 apps/client/src/components/ui/error-banner.module.css create mode 100644 apps/client/src/components/ui/input.module.css create mode 100644 apps/client/src/components/ui/loading-spinner.module.css create mode 100644 apps/client/src/components/ui/modal.module.css create mode 100644 apps/client/src/components/ui/toast.module.css create mode 100644 apps/client/src/components/ui/tooltip.module.css create mode 100644 apps/client/src/contexts/ThemeContext.tsx create mode 100644 apps/client/src/contexts/ToastContext.tsx create mode 100644 apps/client/src/i18n.ts create mode 100644 apps/client/src/main.tsx create mode 100644 apps/client/src/pages/ApartmentDetailPage.tsx create mode 100644 apps/client/src/pages/ApartmentFloorPlanPage.tsx create mode 100644 apps/client/src/pages/ApartmentListPage.tsx create mode 100644 apps/client/src/pages/RoomEditorPage.tsx create mode 100644 apps/client/src/pages/apartment-detail-page.module.css create mode 100644 apps/client/src/pages/apartment-floor-plan-page.module.css create mode 100644 apps/client/src/pages/apartment-list-page.module.css create mode 100644 apps/client/src/pages/room-editor-page.module.css create mode 100644 apps/client/src/router.tsx create mode 100644 apps/client/src/styles/global.css create mode 100644 apps/client/src/styles/tokens.css create mode 100644 apps/client/src/utils/format.ts create mode 100644 apps/client/src/vite-env.d.ts create mode 100644 apps/client/tsconfig.json create mode 100644 apps/client/vite.config.ts create mode 100644 apps/client/vitest.config.ts create mode 100644 apps/server/.env.example create mode 100644 apps/server/package.json create mode 100644 apps/server/prisma/migrations/20260405144549_add_room_position/migration.sql create mode 100644 apps/server/prisma/migrations/migration_lock.toml create mode 100644 apps/server/prisma/schema.prisma create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/src/plugins/error-handler.ts create mode 100644 apps/server/src/plugins/prisma.ts create mode 100644 apps/server/src/routes/apartments.ts create mode 100644 apps/server/src/routes/elements.ts create mode 100644 apps/server/src/routes/rooms.ts create mode 100644 apps/server/src/utils/mappers.ts create mode 100644 apps/server/tsconfig.json create mode 100644 apps/server/vitest.config.ts create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/package-lock.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/__tests__/schemas.test.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/schemas/apartment.schema.ts create mode 100644 packages/shared/src/schemas/elements.schema.ts create mode 100644 packages/shared/src/schemas/room.schema.ts create mode 100644 packages/shared/src/types/apartment.ts create mode 100644 packages/shared/src/types/api.ts create mode 100644 packages/shared/src/types/elements.ts create mode 100644 packages/shared/src/types/room.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/shared/vitest.config.ts create mode 100644 plans/house-plan-maker/CONTEXT.md create mode 100644 plans/house-plan-maker/PLAN.md create mode 100644 plans/house-plan-maker/phase-1-scaffold.md create mode 100644 plans/house-plan-maker/phase-2-data-model-api.md create mode 100644 plans/house-plan-maker/phase-3-management-ui.md create mode 100644 plans/house-plan-maker/phase-4-2d-editor-core.md create mode 100644 plans/house-plan-maker/phase-5-electrical-furniture.md create mode 100644 plans/house-plan-maker/phase-6-wall-projections.md create mode 100644 plans/house-plan-maker/phase-7-3d-view.md create mode 100644 plans/house-plan-maker/phase-8-export-polish.md create mode 100644 tsconfig.json create mode 100644 turbo.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02ddc40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +*.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local + +# Prisma SQLite +*.db +*.db-journal + +# Turbo +.turbo/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test coverage +coverage/ + +# Logs +*.log +npm-debug.log* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1d9d0f6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/apps/client/index.html b/apps/client/index.html new file mode 100644 index 0000000..fea8aac --- /dev/null +++ b/apps/client/index.html @@ -0,0 +1,12 @@ + + + + + + House Plan Maker + + +
+ + + diff --git a/apps/client/package.json b/apps/client/package.json new file mode 100644 index 0000000..8f2c098 --- /dev/null +++ b/apps/client/package.json @@ -0,0 +1,38 @@ +{ + "name": "@house-plan-maker/client", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --build && vite build", + "preview": "vite preview", + "test": "vitest run", + "lint": "eslint src/" + }, + "dependencies": { + "@house-plan-maker/shared": "*", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "i18next": "^26.0.3", + "i18next-browser-languagedetector": "^8.2.1", + "jspdf": "^4.2.1", + "konva": "^9.3.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-i18next": "^17.0.2", + "react-konva": "^19.2.3", + "react-router": "^7.14.0", + "three": "^0.183.2" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/three": "^0.183.1", + "@vitejs/plugin-react": "^4.3.0", + "jsdom": "^26.0.0", + "typescript": "^5.7.0", + "vite": "^6.1.0", + "vitest": "^3.0.0" + } +} diff --git a/apps/client/public/locales/en/translation.json b/apps/client/public/locales/en/translation.json new file mode 100644 index 0000000..ea6a7b1 --- /dev/null +++ b/apps/client/public/locales/en/translation.json @@ -0,0 +1,292 @@ +{ + "app.title": "House Plan Maker", + + "wall.north": "North Wall", + "wall.south": "South Wall", + "wall.east": "East Wall", + "wall.west": "West Wall", + "wall.other": "Wall", + + "furniture.bed": "Bed", + "furniture.desk": "Desk", + "furniture.wardrobe": "Wardrobe", + "furniture.sofa": "Sofa", + "furniture.table": "Table", + "furniture.chair": "Chair", + "furniture.shelf": "Shelf", + "furniture.nightstand": "Nightstand", + "furniture.dresser": "Dresser", + "furniture.bookcase": "Bookcase", + "furniture.tv": "TV", + "furniture.ac_unit": "AC Unit", + "furniture.other": "Other", + + "nav.apartments": "Apartments", + + "breadcrumb.apartments": "Apartments", + "breadcrumb.apartmentDetails": "Apartment Details", + "breadcrumb.roomEditor": "Room Editor", + "breadcrumb.floorPlan": "Floor Plan", + + "apartments.title": "Apartments", + "apartments.new": "New Apartment", + "apartments.create": "Create Apartment", + "apartments.empty.title": "No apartments yet", + "apartments.empty.description": "Create your first apartment to start planning rooms and layouts.", + "apartments.delete.title": "Delete Apartment", + "apartments.delete.message": "Are you sure you want to delete \"{{name}}\"? This will also delete all rooms and their contents. This action cannot be undone.", + "apartments.error.load": "Failed to load apartments", + "apartments.error.operation": "Operation failed", + "apartments.error.delete": "Failed to delete apartment", + + "apartment.notFound": "Apartment not found", + + "apartmentForm.titleNew": "New Apartment", + "apartmentForm.titleEdit": "Edit Apartment", + "apartmentForm.name": "Name", + "apartmentForm.namePlaceholder": "e.g., Main Apartment", + "apartmentForm.address": "Address", + "apartmentForm.addressPlaceholder": "e.g., 123 Main St", + "apartmentForm.totalArea": "Total Area (m\u00b2)", + "apartmentForm.totalAreaPlaceholder": "e.g., 75", + "apartmentForm.saving": "Saving...", + "apartmentForm.creating": "Creating...", + "apartmentForm.saveChanges": "Save Changes", + "apartmentForm.createApartment": "Create Apartment", + + "apartmentCard.area": "Area:", + "apartmentCard.rooms": "Rooms:", + "apartmentCard.edit": "Edit", + "apartmentCard.delete": "Delete", + + "rooms.title": "Rooms", + "rooms.add": "Add Room", + "rooms.addFirst": "Add First Room", + "rooms.fromTemplate": "From Template", + "rooms.empty.title": "No rooms yet", + "rooms.empty.description": "Add rooms to this apartment to start designing floor plans.", + "rooms.delete.title": "Delete Room", + "rooms.delete.message": "Are you sure you want to delete \"{{name}}\"? All walls, openings, electrical items, and furniture in this room will be deleted. This action cannot be undone.", + "rooms.error.load": "Failed to load apartment", + "rooms.error.operation": "Operation failed", + "rooms.error.create": "Failed to create room", + "rooms.error.delete": "Failed to delete room", + "rooms.count": "{{count}} room", + "rooms.count_other": "{{count}} rooms", + "rooms.dimensions": "{{width}} x {{height}} m", + "rooms.vertices": "{{count}} vertices", + "rooms.wallHeight": "Wall height: {{value}} m", + "rooms.plinth": "Plinth: {{value}} m", + + "roomForm.titleNew": "New Room", + "roomForm.titleEdit": "Edit Room", + "roomForm.name": "Room Name", + "roomForm.namePlaceholder": "e.g., Living Room", + "roomForm.shape": "Shape", + "roomForm.rectangular": "Rectangular", + "roomForm.customPolygon": "Custom Polygon", + "roomForm.width": "Width (m)", + "roomForm.widthPlaceholder": "e.g., 4.5", + "roomForm.height": "Height (m)", + "roomForm.heightPlaceholder": "e.g., 3.2", + "roomForm.customNote": "Custom polygon shapes can be drawn in the room editor.", + "roomForm.currentVertices": " Current shape has {{count}} vertices.", + "roomForm.defaultShape": " A default rectangular shape will be created.", + "roomForm.wallProperties": "Wall Properties", + "roomForm.wallHeight": "Wall Height (m)", + "roomForm.plinthProperties": "Plinth Properties", + "roomForm.plinthHeight": "Height (m)", + "roomForm.plinthThickness": "Thickness (m)", + "roomForm.plinthHeightHint": "Default: 0.06 m", + "roomForm.plinthThicknessHint": "Default: 0.01 m", + "roomForm.saving": "Saving...", + "roomForm.creating": "Creating...", + "roomForm.saveChanges": "Save Changes", + "roomForm.createRoom": "Create Room", + "roomForm.widthError": "Width must be positive", + "roomForm.heightError": "Height must be positive", + + "roomCard.edit": "Edit", + "roomCard.delete": "Delete", + + "common.cancel": "Cancel", + "common.delete": "Delete", + "common.deleting": "Deleting...", + "common.loading": "Loading...", + "common.dismissError": "Dismiss error", + + "editor.save": "Save", + "editor.saving": "Saving...", + "editor.saveFailed": "Save failed: {{error}}", + "editor.error.load": "Failed to load room", + "editor.roomNotFound": "Room not found", + "editor.loading3D": "Loading 3D view...", + "editor.unsavedChanges": "You have unsaved changes. Are you sure you want to leave?", + + "toolbar.select": "Select", + "toolbar.door": "Door", + "toolbar.window": "Window", + "toolbar.electrical": "Electrical", + "toolbar.furniture": "Furniture", + "toolbar.measure": "Measure", + "toolbar.annotate": "Annotate", + "toolbar.undo": "Undo (Ctrl+Z)", + "toolbar.redo": "Redo (Ctrl+Shift+Z)", + "toolbar.zoomIn": "Zoom in", + "toolbar.zoomOut": "Zoom out", + "toolbar.grid": "Grid", + "toolbar.snap": "Snap", + "toolbar.walls": "Walls", + "toolbar.elec": "Elec", + "toolbar.furn": "Furn", + "toolbar.meas": "Meas", + "toolbar.toggleGrid": "Toggle grid", + "toolbar.toggleSnap": "Toggle snap", + "toolbar.toggleWalls": "Toggle walls layer", + "toolbar.toggleElectrical": "Toggle electrical layer", + "toolbar.toggleFurniture": "Toggle furniture layer", + "toolbar.toggleMeasurements": "Toggle measurements layer", + "toolbar.import": "Import JSON", + "toolbar.export": "Export (PNG/PDF/JSON)", + "toolbar.saveShortcut": "Save (Ctrl+S)", + "toolbar.view2D": "2D", + "toolbar.view3D": "3D Preview", + "toolbar.viewProjections": "Projections", + "toolbar.alignLeft": "Align left", + "toolbar.alignCenterH": "Center horizontal", + "toolbar.alignRight": "Align right", + "toolbar.alignTop": "Align top", + "toolbar.alignCenterV": "Center vertical", + "toolbar.alignBottom": "Align bottom", + "toolbar.distributeH": "Distribute horizontal", + "toolbar.distributeV": "Distribute vertical", + + "properties.title": "Properties", + "properties.area": "Area", + "properties.perimeter": "Perimeter", + "properties.noSelection": "No element selected", + "properties.selectHint": "Click an element to see its properties", + "properties.multipleSelected": "{{count}} elements selected", + "properties.roomInfo": "Room Info", + "properties.name": "Name", + "properties.wallHeight": "Wall height", + "properties.plinthHeight": "Plinth height", + "properties.walls": "Walls", + "properties.openings": "Openings", + "properties.wall": "Wall", + "properties.length": "Length", + "properties.thickness": "Thickness", + "properties.startX": "Start X", + "properties.startY": "Start Y", + "properties.endX": "End X", + "properties.endY": "End Y", + "properties.direction": "Direction", + "properties.door": "Door", + "properties.window": "Window", + "properties.width": "Width", + "properties.height": "Height", + "properties.position": "Position", + "properties.elevation": "Elevation", + "properties.wallLength": "Wall length", + "properties.type": "Type", + "properties.variant": "Variant", + "properties.x": "X", + "properties.y": "Y", + "properties.rotation": "Rotation", + "properties.wallMounted": "Wall-mounted", + "properties.yes": "Yes", + "properties.depth": "Depth", + "properties.wallColor": "Wall color", + "properties.floorType": "Floor", + "floor.CONCRETE": "Concrete", + "floor.WOOD_LIGHT": "Light Wood", + "floor.WOOD_MEDIUM": "Medium Wood", + "floor.WOOD_DARK": "Dark Wood", + "floor.WOOD_HERRINGBONE": "Herringbone", + "floor.TILE_WHITE": "White Tile", + "floor.TILE_GRAY": "Gray Tile", + "floor.LAMINATE": "Laminate", + "properties.addNote": "Add note", + "properties.stand": "Stand", + "properties.openDirection": "Open direction", + "properties.openDir.LEFT": "Left", + "properties.openDir.RIGHT": "Right", + "properties.openDir.INWARD": "Inward", + "properties.openDir.OUTWARD": "Outward", + "properties.openDir.LEFT": "Left", + "properties.openDir.RIGHT": "Right", + "properties.openDir.INWARD": "Inward", + "properties.openDir.OUTWARD": "Outward", + + "electrical.title": "Electrical", + "electrical.outlets": "Outlets", + "electrical.switches": "Switches", + "electrical.junction": "Junction", + "electrical.lights": "Lights", + "electrical.cable": "Cable", + + "furniture.title": "Furniture", + + "cableLength.label": "Cable length:", + + "projection.title": "Wall Projections", + "projection.grid": "Grid", + "projection.tabs": "Tabs", + "projection.noWall": "No wall selected", + + "templates.title": "New Room from Template", + "templates.create": "Create Room", + "templates.roomName": "Room Name (optional)", + "templates.bedroom": "Bedroom", + "templates.bedroomDesc": "Standard bedroom (4m x 3.5m) with door and window", + "templates.kitchen": "Kitchen", + "templates.kitchenDesc": "Kitchen (3.5m x 3m) with door", + "templates.bathroom": "Bathroom", + "templates.bathroomDesc": "Bathroom (2.5m x 2m)", + "templates.livingRoom": "Living Room", + "templates.livingRoomDesc": "Spacious living room (5m x 4m) with window", + "templates.office": "Office", + "templates.officeDesc": "Home office (3m x 2.5m)", + "templates.emptyRoom": "Empty Room", + "templates.emptyRoomDesc": "Custom empty room (3m x 3m)", + + "export.title": "Export", + "export.format": "Format", + "export.png": "PNG Image", + "export.pdf": "PDF Document", + "export.json": "JSON Data", + "export.scope": "Scope", + "export.currentView": "Current View", + "export.allRoomViews": "All Room Views", + "export.options": "Options", + "export.includeGrid": "Include grid", + "export.scaleFactor": "Scale factor:", + "export.exporting": "Exporting...", + "export.exportBtn": "Export", + "export.generating": "Generating export...", + "export.error.3dNotAvailable": "3D canvas not available", + "export.error.2dNotAvailable": "2D canvas not available", + "export.error.failed": "Export failed", + + "floorPlan.back": "Back", + "floorPlan.reset": "Reset", + "floorPlan.dblClickToEdit": "Double-click to edit", + + "annotation.editPrompt": "Edit annotation text:", + + "editor.importFailed": "Import failed: {{error}}", + + "projection.clickToPlace": "Click on wall to place item", + + "export.pdfTitle": "House Plan", + "export.pdfGenerated": "Generated: {{date}}", + "export.pdfRoomCount": "{{count}} room", + "export.pdfRoomCount_other": "{{count}} rooms", + "export.pdfWallProjections": "{{name}} - Wall Projections", + + "toast.notifications": "Notifications", + "toast.dismiss": "Dismiss notification", + + "theme.toggle": "Toggle dark mode", + "language.toggle": "Switch language" +} diff --git a/apps/client/public/locales/ru/translation.json b/apps/client/public/locales/ru/translation.json new file mode 100644 index 0000000..5cf23a3 --- /dev/null +++ b/apps/client/public/locales/ru/translation.json @@ -0,0 +1,298 @@ +{ + "app.title": "Планировщик квартир", + + "wall.north": "Северная стена", + "wall.south": "Южная стена", + "wall.east": "Восточная стена", + "wall.west": "Западная стена", + "wall.other": "Стена", + + "furniture.bed": "Кровать", + "furniture.desk": "Стол", + "furniture.wardrobe": "Шкаф", + "furniture.sofa": "Диван", + "furniture.table": "Стол обеденный", + "furniture.chair": "Стул", + "furniture.shelf": "Полка", + "furniture.nightstand": "Тумбочка", + "furniture.dresser": "Комод", + "furniture.bookcase": "Книжный шкаф", + "furniture.tv": "Телевизор", + "furniture.ac_unit": "Кондиционер", + "furniture.other": "Другое", + + "nav.apartments": "Квартиры", + + "breadcrumb.apartments": "Квартиры", + "breadcrumb.apartmentDetails": "Детали квартиры", + "breadcrumb.roomEditor": "Редактор комнаты", + "breadcrumb.floorPlan": "План этажа", + + "apartments.title": "Квартиры", + "apartments.new": "Новая квартира", + "apartments.create": "Создать квартиру", + "apartments.empty.title": "Нет квартир", + "apartments.empty.description": "Создайте первую квартиру, чтобы начать планировку комнат.", + "apartments.delete.title": "Удалить квартиру", + "apartments.delete.message": "Вы уверены, что хотите удалить \"{{name}}\"? Все комнаты и их содержимое также будут удалены. Это действие нельзя отменить.", + "apartments.error.load": "Не удалось загрузить квартиры", + "apartments.error.operation": "Операция не удалась", + "apartments.error.delete": "Не удалось удалить квартиру", + + "apartment.notFound": "Квартира не найдена", + + "apartmentForm.titleNew": "Новая квартира", + "apartmentForm.titleEdit": "Редактировать квартиру", + "apartmentForm.name": "Название", + "apartmentForm.namePlaceholder": "напр., Основная квартира", + "apartmentForm.address": "Адрес", + "apartmentForm.addressPlaceholder": "напр., ул. Главная 123", + "apartmentForm.totalArea": "Общая площадь (м\u00b2)", + "apartmentForm.totalAreaPlaceholder": "напр., 75", + "apartmentForm.saving": "Сохранение...", + "apartmentForm.creating": "Создание...", + "apartmentForm.saveChanges": "Сохранить изменения", + "apartmentForm.createApartment": "Создать квартиру", + + "apartmentCard.area": "Площадь:", + "apartmentCard.rooms": "Комнаты:", + "apartmentCard.edit": "Изменить", + "apartmentCard.delete": "Удалить", + + "rooms.title": "Комнаты", + "rooms.add": "Добавить комнату", + "rooms.addFirst": "Добавить первую комнату", + "rooms.fromTemplate": "Из шаблона", + "rooms.empty.title": "Нет комнат", + "rooms.empty.description": "Добавьте комнаты для создания планировок.", + "rooms.delete.title": "Удалить комнату", + "rooms.delete.message": "Вы уверены, что хотите удалить \"{{name}}\"? Все стены, проёмы, электрика и мебель в этой комнате будут удалены. Это действие нельзя отменить.", + "rooms.error.load": "Не удалось загрузить квартиру", + "rooms.error.operation": "Операция не удалась", + "rooms.error.create": "Не удалось создать комнату", + "rooms.error.delete": "Не удалось удалить комнату", + "rooms.count": "{{count}} комната", + "rooms.count_one": "{{count}} комната", + "rooms.count_few": "{{count}} комнаты", + "rooms.count_many": "{{count}} комнат", + "rooms.count_other": "{{count}} комнат", + "rooms.dimensions": "{{width}} x {{height}} м", + "rooms.vertices": "{{count}} вершин", + "rooms.wallHeight": "Высота стен: {{value}} м", + "rooms.plinth": "Плинтус: {{value}} м", + + "roomForm.titleNew": "Новая комната", + "roomForm.titleEdit": "Редактировать комнату", + "roomForm.name": "Название комнаты", + "roomForm.namePlaceholder": "напр., Гостиная", + "roomForm.shape": "Форма", + "roomForm.rectangular": "Прямоугольная", + "roomForm.customPolygon": "Произвольный многоугольник", + "roomForm.width": "Ширина (м)", + "roomForm.widthPlaceholder": "напр., 4,5", + "roomForm.height": "Длина (м)", + "roomForm.heightPlaceholder": "напр., 3,2", + "roomForm.customNote": "Произвольные формы можно нарисовать в редакторе комнаты.", + "roomForm.currentVertices": " Текущая форма имеет {{count}} вершин.", + "roomForm.defaultShape": " Будет создана прямоугольная форма по умолчанию.", + "roomForm.wallProperties": "Свойства стен", + "roomForm.wallHeight": "Высота стен (м)", + "roomForm.plinthProperties": "Свойства плинтуса", + "roomForm.plinthHeight": "Высота (м)", + "roomForm.plinthThickness": "Толщина (м)", + "roomForm.plinthHeightHint": "По умолчанию: 0,06 м", + "roomForm.plinthThicknessHint": "По умолчанию: 0,01 м", + "roomForm.saving": "Сохранение...", + "roomForm.creating": "Создание...", + "roomForm.saveChanges": "Сохранить изменения", + "roomForm.createRoom": "Создать комнату", + "roomForm.widthError": "Ширина должна быть положительной", + "roomForm.heightError": "Высота должна быть положительной", + + "roomCard.edit": "Изменить", + "roomCard.delete": "Удалить", + + "common.cancel": "Отмена", + "common.delete": "Удалить", + "common.deleting": "Удаление...", + "common.loading": "Загрузка...", + "common.dismissError": "Закрыть ошибку", + + "editor.save": "Сохранить", + "editor.saving": "Сохранение...", + "editor.saveFailed": "Ошибка сохранения: {{error}}", + "editor.error.load": "Не удалось загрузить комнату", + "editor.roomNotFound": "Комната не найдена", + "editor.loading3D": "Загрузка 3D вида...", + "editor.unsavedChanges": "У вас есть несохранённые изменения. Вы уверены, что хотите уйти?", + + "toolbar.select": "Выбрать", + "toolbar.door": "Дверь", + "toolbar.window": "Окно", + "toolbar.electrical": "Электрика", + "toolbar.furniture": "Мебель", + "toolbar.measure": "Измерить", + "toolbar.annotate": "Аннотация", + "toolbar.undo": "Отменить (Ctrl+Z)", + "toolbar.redo": "Повторить (Ctrl+Shift+Z)", + "toolbar.zoomIn": "Увеличить", + "toolbar.zoomOut": "Уменьшить", + "toolbar.grid": "Сетка", + "toolbar.snap": "Привязка", + "toolbar.walls": "Стены", + "toolbar.elec": "Элек", + "toolbar.furn": "Мебель", + "toolbar.meas": "Разм", + "toolbar.toggleGrid": "Переключить сетку", + "toolbar.toggleSnap": "Переключить привязку", + "toolbar.toggleWalls": "Переключить слой стен", + "toolbar.toggleElectrical": "Переключить слой электрики", + "toolbar.toggleFurniture": "Переключить слой мебели", + "toolbar.toggleMeasurements": "Переключить слой размеров", + "toolbar.import": "Импорт JSON", + "toolbar.export": "Экспорт (PNG/PDF/JSON)", + "toolbar.saveShortcut": "Сохранить (Ctrl+S)", + "toolbar.view2D": "2D", + "toolbar.view3D": "3D Просмотр", + "toolbar.viewProjections": "Проекции", + "toolbar.alignLeft": "Выровнять по левому краю", + "toolbar.alignCenterH": "Центрировать по горизонтали", + "toolbar.alignRight": "Выровнять по правому краю", + "toolbar.alignTop": "Выровнять по верхнему краю", + "toolbar.alignCenterV": "Центрировать по вертикали", + "toolbar.alignBottom": "Выровнять по нижнему краю", + "toolbar.distributeH": "Распределить по горизонтали", + "toolbar.distributeV": "Распределить по вертикали", + + "properties.title": "Свойства", + "properties.area": "Площадь", + "properties.perimeter": "Периметр", + "properties.noSelection": "Элемент не выбран", + "properties.selectHint": "Нажмите на элемент, чтобы увидеть его свойства", + "properties.multipleSelected": "Выбрано элементов: {{count}}", + "properties.roomInfo": "Информация о комнате", + "properties.name": "Название", + "properties.wallHeight": "Высота стен", + "properties.plinthHeight": "Высота плинтуса", + "properties.walls": "Стены", + "properties.openings": "Проёмы", + "properties.wall": "Стена", + "properties.length": "Длина", + "properties.thickness": "Толщина", + "properties.startX": "Начало X", + "properties.startY": "Начало Y", + "properties.endX": "Конец X", + "properties.endY": "Конец Y", + "properties.direction": "Направление", + "properties.door": "Дверь", + "properties.window": "Окно", + "properties.width": "Ширина", + "properties.height": "Высота", + "properties.position": "Положение", + "properties.elevation": "Высота от пола", + "properties.wallLength": "Длина стены", + "properties.type": "Тип", + "properties.variant": "Вариант", + "properties.x": "X", + "properties.y": "Y", + "properties.rotation": "Поворот", + "properties.wallMounted": "На стене", + "properties.yes": "Да", + "properties.depth": "Глубина", + "properties.wallColor": "Цвет стен", + "properties.floorType": "Пол", + "floor.CONCRETE": "Бетон", + "floor.WOOD_LIGHT": "Светлое дерево", + "floor.WOOD_MEDIUM": "Среднее дерево", + "floor.WOOD_DARK": "Тёмное дерево", + "floor.WOOD_HERRINGBONE": "Ёлочка", + "floor.TILE_WHITE": "Белая плитка", + "floor.TILE_GRAY": "Серая плитка", + "floor.LAMINATE": "Ламинат", + "properties.addNote": "Добавить заметку", + "properties.stand": "Подставка", + "properties.openDirection": "Направление открытия", + "properties.openDir.LEFT": "Влево", + "properties.openDir.RIGHT": "Вправо", + "properties.openDir.INWARD": "Внутрь", + "properties.openDir.OUTWARD": "Наружу", + "properties.openDir.LEFT": "Влево", + "properties.openDir.RIGHT": "Вправо", + "properties.openDir.INWARD": "Внутрь", + "properties.openDir.OUTWARD": "Наружу", + + "electrical.title": "Электрика", + "electrical.outlets": "Розетки", + "electrical.switches": "Выключатели", + "electrical.junction": "Распределительная коробка", + "electrical.lights": "Освещение", + "electrical.cable": "Кабель", + + "furniture.title": "Мебель", + + "cableLength.label": "Длина кабеля:", + + "projection.title": "Проекции стен", + "projection.grid": "Сетка", + "projection.tabs": "Вкладки", + "projection.noWall": "Стена не выбрана", + + "templates.title": "Новая комната из шаблона", + "templates.create": "Создать комнату", + "templates.roomName": "Название комнаты (необязательно)", + "templates.bedroom": "Спальня", + "templates.bedroomDesc": "Стандартная спальня (4м x 3,5м) с дверью и окном", + "templates.kitchen": "Кухня", + "templates.kitchenDesc": "Кухня (3,5м x 3м) с дверью", + "templates.bathroom": "Ванная", + "templates.bathroomDesc": "Ванная комната (2,5м x 2м)", + "templates.livingRoom": "Гостиная", + "templates.livingRoomDesc": "Просторная гостиная (5м x 4м) с окном", + "templates.office": "Кабинет", + "templates.officeDesc": "Домашний кабинет (3м x 2,5м)", + "templates.emptyRoom": "Пустая комната", + "templates.emptyRoomDesc": "Пустая комната (3м x 3м)", + + "export.title": "Экспорт", + "export.format": "Формат", + "export.png": "PNG изображение", + "export.pdf": "PDF документ", + "export.json": "JSON данные", + "export.scope": "Область", + "export.currentView": "Текущий вид", + "export.allRoomViews": "Все виды комнаты", + "export.options": "Параметры", + "export.includeGrid": "Включить сетку", + "export.scaleFactor": "Масштаб:", + "export.exporting": "Экспорт...", + "export.exportBtn": "Экспорт", + "export.generating": "Создание экспорта...", + "export.error.3dNotAvailable": "3D холст недоступен", + "export.error.2dNotAvailable": "2D холст недоступен", + "export.error.failed": "Ошибка экспорта", + + "floorPlan.back": "Назад", + "floorPlan.reset": "Сброс", + "floorPlan.dblClickToEdit": "Двойной клик для редактирования", + + "annotation.editPrompt": "Редактировать текст аннотации:", + + "editor.importFailed": "Ошибка импорта: {{error}}", + + "projection.clickToPlace": "Нажмите на стену, чтобы разместить элемент", + + "export.pdfTitle": "План дома", + "export.pdfGenerated": "Создано: {{date}}", + "export.pdfRoomCount": "{{count}} комната", + "export.pdfRoomCount_one": "{{count}} комната", + "export.pdfRoomCount_few": "{{count}} комнаты", + "export.pdfRoomCount_many": "{{count}} комнат", + "export.pdfRoomCount_other": "{{count}} комнат", + "export.pdfWallProjections": "{{name}} — Проекции стен", + + "toast.notifications": "Уведомления", + "toast.dismiss": "Закрыть уведомление", + + "theme.toggle": "Переключить тёмную тему", + "language.toggle": "Сменить язык" +} diff --git a/apps/client/src/api/client.ts b/apps/client/src/api/client.ts new file mode 100644 index 0000000..376c7d6 --- /dev/null +++ b/apps/client/src/api/client.ts @@ -0,0 +1,340 @@ +import type { + Apartment, + ApartmentWithRooms, + Room, + RoomFull, + Wall, + WallOpening, + ElectricalItem, + FurnitureItem, + CreateApartmentDto, + UpdateApartmentDto, + CreateRoomDto, + UpdateRoomDto, + CreateWallDto, + CreateWallOpeningDto, + UpdateWallOpeningDto, + CreateElectricalItemDto, + UpdateElectricalItemDto, + CreateFurnitureItemDto, + UpdateFurnitureItemDto, + BatchSyncOpeningsDto, + BatchSyncElectricalDto, + BatchSyncFurnitureDto, + ApiResponse, + ApiListResponse, + ApiErrorResponse, +} from '@house-plan-maker/shared'; + +const BASE_URL = '/api'; + +class ApiError extends Error { + readonly statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = 'ApiError'; + this.statusCode = statusCode; + } +} + +async function request( + path: string, + options: RequestInit = {}, +): Promise { + const url = `${BASE_URL}${path}`; + const headers: Record = { + ...((options.headers as Record) ?? {}), + }; + + // Only set Content-Type for requests with a body + if (options.body) { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(url, { ...options, headers }); + + if (!response.ok) { + let errorMessage = `Request failed with status ${response.status}`; + try { + const errorBody = (await response.json()) as ApiErrorResponse; + errorMessage = errorBody.error ?? errorMessage; + } catch { + // If parsing fails, use the default message + } + throw new ApiError(errorMessage, response.status); + } + + // Handle 204 No Content (e.g. DELETE responses) + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} + +// ── Apartments ── + +export async function getApartments(): Promise { + const result = await request>('/apartments'); + return result.data; +} + +export async function getApartment(id: string): Promise { + const result = await request>( + `/apartments/${id}`, + ); + return result.data; +} + +export async function createApartment( + data: CreateApartmentDto, +): Promise { + const result = await request>('/apartments', { + method: 'POST', + body: JSON.stringify(data), + }); + return result.data; +} + +export async function updateApartment( + id: string, + data: UpdateApartmentDto, +): Promise { + const result = await request>(`/apartments/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + return result.data; +} + +export async function deleteApartment(id: string): Promise { + await request(`/apartments/${id}`, { method: 'DELETE' }); +} + +// ── Rooms ── + +export async function getRooms(apartmentId: string): Promise { + const result = await request>( + `/apartments/${apartmentId}/rooms`, + ); + return result.data; +} + +export async function getRoomFull(roomId: string): Promise { + const result = await request>( + `/rooms/${roomId}/full`, + ); + return result.data; +} + +export async function createRoom( + apartmentId: string, + data: CreateRoomDto, +): Promise { + const result = await request>( + `/apartments/${apartmentId}/rooms`, + { + method: 'POST', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export async function updateRoom( + id: string, + data: UpdateRoomDto, +): Promise { + const result = await request>(`/rooms/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + return result.data; +} + +export async function deleteRoom(id: string): Promise { + await request(`/rooms/${id}`, { method: 'DELETE' }); +} + +// ── Walls ── + +export async function bulkUpdateWalls( + roomId: string, + walls: readonly CreateWallDto[], +): Promise { + const result = await request>( + `/rooms/${roomId}/walls`, + { + method: 'PUT', + body: JSON.stringify({ walls }), + }, + ); + return result.data; +} + +// ── Wall Openings ── + +export async function createWallOpening( + roomId: string, + data: CreateWallOpeningDto, +): Promise { + const result = await request>( + `/rooms/${roomId}/openings`, + { + method: 'POST', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export async function updateWallOpening( + roomId: string, + openingId: string, + data: UpdateWallOpeningDto, +): Promise { + const result = await request>( + `/rooms/${roomId}/openings/${openingId}`, + { + method: 'PUT', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export async function deleteWallOpening( + roomId: string, + openingId: string, +): Promise { + await request(`/rooms/${roomId}/openings/${openingId}`, { + method: 'DELETE', + }); +} + +// ── Electrical Items ── + +export async function createElectricalItem( + roomId: string, + data: CreateElectricalItemDto, +): Promise { + const result = await request>( + `/rooms/${roomId}/electrical`, + { + method: 'POST', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export async function updateElectricalItem( + roomId: string, + itemId: string, + data: UpdateElectricalItemDto, +): Promise { + const result = await request>( + `/electrical/${itemId}`, + { + method: 'PUT', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export async function deleteElectricalItem( + roomId: string, + itemId: string, +): Promise { + await request(`/electrical/${itemId}`, { + method: 'DELETE', + }); +} + +// ── Furniture Items ── + +export async function createFurnitureItem( + roomId: string, + data: CreateFurnitureItemDto, +): Promise { + const result = await request>( + `/rooms/${roomId}/furniture`, + { + method: 'POST', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export async function updateFurnitureItem( + roomId: string, + itemId: string, + data: UpdateFurnitureItemDto, +): Promise { + const result = await request>( + `/furniture/${itemId}`, + { + method: 'PUT', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export async function deleteFurnitureItem( + roomId: string, + itemId: string, +): Promise { + await request(`/furniture/${itemId}`, { + method: 'DELETE', + }); +} + +// ── Batch Sync ── + +export async function batchSyncOpenings( + roomId: string, + data: BatchSyncOpeningsDto, +): Promise { + const result = await request>( + `/rooms/${roomId}/openings/batch`, + { + method: 'PUT', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export async function batchSyncElectrical( + roomId: string, + data: BatchSyncElectricalDto, +): Promise { + const result = await request>( + `/rooms/${roomId}/electrical/batch`, + { + method: 'PUT', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export async function batchSyncFurniture( + roomId: string, + data: BatchSyncFurnitureDto, +): Promise { + const result = await request>( + `/rooms/${roomId}/furniture/batch`, + { + method: 'PUT', + body: JSON.stringify(data), + }, + ); + return result.data; +} + +export { ApiError }; diff --git a/apps/client/src/components/apartments/ApartmentCard.tsx b/apps/client/src/components/apartments/ApartmentCard.tsx new file mode 100644 index 0000000..f08d21d --- /dev/null +++ b/apps/client/src/components/apartments/ApartmentCard.tsx @@ -0,0 +1,72 @@ +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import type { Apartment } from '@house-plan-maker/shared'; +import { Card, CardHeader, CardBody } from '../ui/Card'; +import { Button } from '../ui/Button'; +import styles from './apartment-card.module.css'; + +interface ApartmentCardProps { + apartment: Apartment; + roomCount: number; + onEdit: (apartment: Apartment) => void; + onDelete: (apartment: Apartment) => void; +} + +export function ApartmentCard({ + apartment, + roomCount, + onEdit, + onDelete, +}: ApartmentCardProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleClick = () => { + navigate(`/apartments/${apartment.id}`); + }; + + const handleEdit = (event: React.MouseEvent) => { + event.stopPropagation(); + onEdit(apartment); + }; + + const handleDelete = (event: React.MouseEvent) => { + event.stopPropagation(); + onDelete(apartment); + }; + + return ( + + +
+

{apartment.name}

+ {apartment.address && ( +

{apartment.address}

+ )} +
+
+ + +
+
+ +
+ {apartment.totalArea != null && ( + + {t('apartmentCard.area')} + {apartment.totalArea} m² + + )} + + {t('apartmentCard.rooms')} + {roomCount} + +
+
+
+ ); +} diff --git a/apps/client/src/components/apartments/ApartmentFormModal.tsx b/apps/client/src/components/apartments/ApartmentFormModal.tsx new file mode 100644 index 0000000..97b1b17 --- /dev/null +++ b/apps/client/src/components/apartments/ApartmentFormModal.tsx @@ -0,0 +1,134 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Apartment } from '@house-plan-maker/shared'; +import { createApartmentSchema } from '@house-plan-maker/shared'; +import { Modal } from '../ui/Modal'; +import { Input } from '../ui/Input'; +import { Button } from '../ui/Button'; +import styles from './apartment-form-modal.module.css'; + +interface ApartmentFormModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: { name: string; address: string; totalArea: string }) => void; + apartment?: Apartment | null; + loading?: boolean; +} + +interface FormErrors { + name?: string; + address?: string; + totalArea?: string; +} + +export function ApartmentFormModal({ + open, + onClose, + onSubmit, + apartment, + loading = false, +}: ApartmentFormModalProps) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [address, setAddress] = useState(''); + const [totalArea, setTotalArea] = useState(''); + const [errors, setErrors] = useState({}); + + const isEditing = apartment != null; + + useEffect(() => { + if (open) { + setName(apartment?.name ?? ''); + setAddress(apartment?.address ?? ''); + setTotalArea(apartment?.totalArea != null ? String(apartment.totalArea) : ''); + setErrors({}); + } + }, [open, apartment]); + + const validate = (): boolean => { + const result = createApartmentSchema.safeParse({ + name: name.trim(), + address: address.trim() || null, + totalArea: totalArea.trim() ? Number(totalArea) : null, + }); + + if (!result.success) { + const fieldErrors: FormErrors = {}; + for (const issue of result.error.issues) { + const field = issue.path[0] as keyof FormErrors; + if (!fieldErrors[field]) { + fieldErrors[field] = issue.message; + } + } + setErrors(fieldErrors); + return false; + } + + setErrors({}); + return true; + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (validate()) { + onSubmit({ name: name.trim(), address: address.trim(), totalArea: totalArea.trim() }); + } + }; + + return ( + + + + + } + > +
+ setName(e.target.value)} + error={errors.name} + placeholder={t('apartmentForm.namePlaceholder')} + autoFocus + required + /> + setAddress(e.target.value)} + error={errors.address} + placeholder={t('apartmentForm.addressPlaceholder')} + /> + setTotalArea(e.target.value)} + error={errors.totalArea} + placeholder={t('apartmentForm.totalAreaPlaceholder')} + min="0" + step="0.01" + /> +
+
+ ); +} diff --git a/apps/client/src/components/apartments/apartment-card.module.css b/apps/client/src/components/apartments/apartment-card.module.css new file mode 100644 index 0000000..ee9bd17 --- /dev/null +++ b/apps/client/src/components/apartments/apartment-card.module.css @@ -0,0 +1,40 @@ +.card { + position: relative; +} + +.name { + font-size: var(--font-size-md); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0; +} + +.address { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin-top: var(--space-1); +} + +.meta { + display: flex; + align-items: center; + gap: var(--space-4); + margin-top: var(--space-3); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.metaItem { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.metaLabel { + color: var(--color-text-muted); +} + +.actions { + display: flex; + gap: var(--space-1); +} diff --git a/apps/client/src/components/apartments/apartment-form-modal.module.css b/apps/client/src/components/apartments/apartment-form-modal.module.css new file mode 100644 index 0000000..190f0d4 --- /dev/null +++ b/apps/client/src/components/apartments/apartment-form-modal.module.css @@ -0,0 +1,5 @@ +.form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} diff --git a/apps/client/src/components/editor/EditorCanvas.tsx b/apps/client/src/components/editor/EditorCanvas.tsx new file mode 100644 index 0000000..d25c12c --- /dev/null +++ b/apps/client/src/components/editor/EditorCanvas.tsx @@ -0,0 +1,621 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Stage } from 'react-konva'; +import type Konva from 'konva'; +import type { Point } from '@house-plan-maker/shared'; +import { useZoomPan, useSelection, useSceneData } from './context/EditorContext'; +import { useUndoRedo } from './context/UndoRedoContext'; +import { useEditorZoom } from './hooks/useEditorZoom'; +import { GridLayer } from './layers/GridLayer'; +import { WallLayer } from './layers/WallLayer'; +import { OpeningLayer, type OpeningPreview } from './layers/OpeningLayer'; +import { ElectricalLayer } from './layers/ElectricalLayer'; +import { FurnitureLayer } from './layers/FurnitureLayer'; +import { SelectionLayer } from './layers/SelectionLayer'; +import { MeasurementLayer } from './layers/MeasurementLayer'; +import { RoomLabelLayer } from './layers/RoomLabelLayer'; +import { ScaleBar } from './overlays/ScaleBar'; +import { + hitTest, + selectionRect, + elementsInRect, + selectedBoundingBox, +} from './tools/SelectTool'; +import { computeDoorPreview, createDoorOpening } from './tools/DoorTool'; +import { computeWindowPreview, createWindowOpening } from './tools/WindowTool'; +import { computeElectricalPreview, createElectricalItemFromPlacement } from './tools/ElectricalTool'; +import { computeFurniturePreview, createFurnitureItemFromPlacement } from './tools/FurnitureTool'; +import { startMeasurement, updateMeasurement, finishMeasurement } from './tools/MeasureTool'; +import { ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical'; +import { FURNITURE_DEFS } from './symbols/furniture'; +import { AnnotationLayer } from './layers/AnnotationLayer'; +import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer'; +import { generateLocalId } from './utils/geometry'; +import type { EditorCommand, MeasurementState } from './types'; + +interface EditorCanvasProps { + readonly width: number; + readonly height: number; + readonly onStageRef?: (stage: Konva.Stage | null) => void; +} + +/** Cursor styles per tool. */ +const TOOL_CURSORS: Record = { + select: 'default', + door: 'crosshair', + window: 'crosshair', + electrical: 'crosshair', + furniture: 'crosshair', + measure: 'crosshair', + annotate: 'text', +}; + +export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) { + const { t } = useTranslation(); + const { zoom, panOffset, setZoom, setPanOffset } = useZoomPan(); + const { + selectedIds, + activeTool, + dispatch: selectionDispatch, + selectElement, + clearSelection, + } = useSelection(); + const { + walls, + openings, + electricalItems, + furnitureItems, + room, + gridSize, + gridVisible, + layerVisibility, + selectedElectricalIndex, + selectedFurnitureIndex, + annotations, + dispatch: sceneDispatch, + addOpening, + addElectrical, + addFurniture, + addAnnotation, + updateAnnotation, + } = useSceneData(); + const { execute } = useUndoRedo(); + + // Use scene dispatch for actions that affect scene data + const dispatch = sceneDispatch; + + const stageRef = useRef(null); + + // Task 5: Fix useEffect missing dependency array + useEffect(() => { + onStageRef?.(stageRef.current); + }, [onStageRef]); + + const { + handleWheel, + screenToWorld, + isPanningRef, + handlePanStart, + startLeftMousePan, + handlePanMove, + handlePanEnd, + } = useEditorZoom({ + zoom, + panOffset, + onZoomChange: (z) => setZoom(z), + onPanChange: (offset) => setPanOffset(offset), + }); + + // ── Selection drag state ── + const [dragRect, setDragRect] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + const dragStartRef = useRef(null); + const isDraggingSelectRef = useRef(false); + + // ── Item drag state (moving selected items) ── + const isDraggingItemRef = useRef(false); + const dragItemStartRef = useRef(null); + const dragItemSnapshotRef = useRef>(new Map()); + + // ── Opening placement preview ── + const [openingPreview, setOpeningPreview] = useState(null); + + // ── Measurement tool state ��─ + const [measureState, setMeasureState] = useState(null); + const isMeasuringRef = useRef(false); + + // ── Task 4: Memoize selection bounding box ── + const selBox = useMemo( + () => + selectedIds.size > 0 + ? selectedBoundingBox(selectedIds, openings, walls, electricalItems, furnitureItems) + : null, + [selectedIds, openings, walls, electricalItems, furnitureItems], + ); + + // ── Get world coordinates from a Konva mouse event ── + const getWorldPoint = useCallback( + (e: Konva.KonvaEventObject): Point => { + const stage = e.target.getStage(); + const pos = stage?.getPointerPosition(); + if (!pos) return { x: 0, y: 0 }; + return screenToWorld(pos.x, pos.y); + }, + [screenToWorld], + ); + + // ── Mouse Down ── + const handleMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + // Handle panning first + handlePanStart(e); + if (e.evt.button === 1) return; // middle mouse = pan only + + if (e.evt.button !== 0) return; // only left click + + const worldPoint = getWorldPoint(e); + + if (activeTool === 'select') { + const hit = hitTest(worldPoint, openings, walls, electricalItems, furnitureItems); + + if (hit) { + if (e.evt.shiftKey) { + // Toggle selection with shift + if (selectedIds.has(hit.id)) { + selectionDispatch({ type: 'REMOVE_FROM_SELECTION', id: hit.id }); + } else { + selectionDispatch({ type: 'ADD_TO_SELECTION', id: hit.id }); + } + } else { + if (!selectedIds.has(hit.id)) { + selectElement(hit.id); + } + // Start item drag — snapshot starting positions + isDraggingItemRef.current = true; + dragItemStartRef.current = worldPoint; + const snap = new Map(); + for (const id of selectedIds) { + const elec = electricalItems.find((i) => i.id === id); + if (elec) { snap.set(id, { x: elec.x, y: elec.y }); continue; } + const furn = furnitureItems.find((i) => i.id === id); + if (furn) { snap.set(id, { x: furn.x, y: furn.y }); } + } + // Include the hit item if not yet in selection + if (!snap.has(hit.id)) { + const elec = electricalItems.find((i) => i.id === hit.id); + if (elec) snap.set(hit.id, { x: elec.x, y: elec.y }); + const furn = furnitureItems.find((i) => i.id === hit.id); + if (furn) snap.set(hit.id, { x: furn.x, y: furn.y }); + } + dragItemSnapshotRef.current = snap; + } + } else { + if (e.evt.ctrlKey || e.evt.metaKey) { + // Ctrl+click on empty space: start drag select rectangle + if (!e.evt.shiftKey) { + clearSelection(); + } + dragStartRef.current = worldPoint; + isDraggingSelectRef.current = true; + } else { + // Left click on empty space: start panning + clearSelection(); + startLeftMousePan(e); + } + } + } else if (activeTool === 'door') { + const preview = computeDoorPreview(worldPoint, walls, openings); + if (preview && preview.isValid) { + const newOpening = createDoorOpening( + room.id, + preview.wall.id, + preview.positionAlongWall, + preview.width, + ); + const cmd: EditorCommand = { + description: 'Place door', + execute: () => addOpening(newOpening), + undo: () => dispatch({ type: 'REMOVE_OPENING', id: newOpening.id }), + }; + execute(cmd); + } + } else if (activeTool === 'window') { + const preview = computeWindowPreview(worldPoint, walls, openings); + if (preview && preview.isValid) { + const newOpening = createWindowOpening( + room.id, + preview.wall.id, + preview.positionAlongWall, + preview.width, + ); + const cmd: EditorCommand = { + description: 'Place window', + execute: () => addOpening(newOpening), + undo: () => dispatch({ type: 'REMOVE_OPENING', id: newOpening.id }), + }; + execute(cmd); + } + } else if (activeTool === 'electrical' && selectedElectricalIndex != null) { + const symbolDef = ELECTRICAL_SYMBOL_DEFS[selectedElectricalIndex]; + if (symbolDef) { + const preview = computeElectricalPreview(worldPoint, walls, symbolDef); + if (preview.isValid) { + const newItem = createElectricalItemFromPlacement( + room.id, + preview, + symbolDef.type, + symbolDef.variant, + room.wallHeight, + ); + const cmd: EditorCommand = { + description: `Place ${symbolDef.label}`, + execute: () => addElectrical(newItem), + undo: () => dispatch({ type: 'REMOVE_ELECTRICAL', id: newItem.id }), + }; + execute(cmd); + } + } + } else if (activeTool === 'furniture' && selectedFurnitureIndex != null) { + const furnitureDef = FURNITURE_DEFS[selectedFurnitureIndex]; + if (furnitureDef) { + const preview = computeFurniturePreview(worldPoint, furnitureDef); + if (preview.isValid) { + const newItem = createFurnitureItemFromPlacement(room.id, preview, furnitureDef); + const cmd: EditorCommand = { + description: `Place ${furnitureDef.label}`, + execute: () => addFurniture(newItem), + undo: () => dispatch({ type: 'REMOVE_FURNITURE', id: newItem.id }), + }; + execute(cmd); + } + } + } else if (activeTool === 'measure') { + const ms = startMeasurement(worldPoint); + setMeasureState(ms); + isMeasuringRef.current = true; + } else if (activeTool === 'annotate') { + const newAnnotation = { + id: generateLocalId(), + roomId: room.id, + x: worldPoint.x, + y: worldPoint.y, + text: 'Text', + fontSize: 14, + color: '#333333', + }; + const cmd: EditorCommand = { + description: 'Place annotation', + execute: () => addAnnotation(newAnnotation), + undo: () => dispatch({ type: 'REMOVE_ANNOTATION', id: newAnnotation.id }), + }; + execute(cmd); + selectionDispatch({ type: 'SET_SELECTED', ids: new Set([newAnnotation.id]) }); + } + }, + [ + handlePanStart, + getWorldPoint, + activeTool, + openings, + walls, + electricalItems, + furnitureItems, + selectedIds, + selectElement, + clearSelection, + selectionDispatch, + dispatch, + room.id, + addOpening, + addElectrical, + addFurniture, + addAnnotation, + execute, + selectedElectricalIndex, + selectedFurnitureIndex, + ], + ); + + // ── Task 3: RAF-throttled mouse move ── + const rafIdRef = useRef(null); + const pendingMoveRef = useRef | null>(null); + + const processMouseMove = useCallback( + (e: Konva.KonvaEventObject) => { + handlePanMove(e); + + if (isPanningRef.current) return; + + const worldPoint = getWorldPoint(e); + + // Move selected items by dragging — use snapshot + total delta from start + if (isDraggingItemRef.current && dragItemStartRef.current && selectedIds.size > 0) { + const totalDx = worldPoint.x - dragItemStartRef.current.x; + const totalDy = worldPoint.y - dragItemStartRef.current.y; + + for (const id of selectedIds) { + const startPos = dragItemSnapshotRef.current.get(id); + if (!startPos) continue; + + const elec = electricalItems.find((item) => item.id === id); + if (elec) { + dispatch({ type: 'UPDATE_ELECTRICAL', item: { ...elec, x: startPos.x + totalDx, y: startPos.y + totalDy } }); + continue; + } + const furn = furnitureItems.find((item) => item.id === id); + if (furn) { + dispatch({ type: 'UPDATE_FURNITURE', item: { ...furn, x: startPos.x + totalDx, y: startPos.y + totalDy } }); + } + } + return; + } + + // Drag select rectangle + if (isDraggingSelectRef.current && dragStartRef.current) { + const rect = selectionRect(dragStartRef.current, worldPoint); + setDragRect(rect); + return; + } + + // Opening placement preview + if (activeTool === 'door') { + const preview = computeDoorPreview(worldPoint, walls, openings); + setOpeningPreview( + preview + ? { + wall: preview.wall, + positionAlongWall: preview.positionAlongWall, + width: preview.width, + type: 'DOOR', + isValid: preview.isValid, + } + : null, + ); + } else if (activeTool === 'window') { + const preview = computeWindowPreview(worldPoint, walls, openings); + setOpeningPreview( + preview + ? { + wall: preview.wall, + positionAlongWall: preview.positionAlongWall, + width: preview.width, + type: 'WINDOW', + isValid: preview.isValid, + } + : null, + ); + } else { + setOpeningPreview(null); + } + + // Measurement tool: update end point while dragging + if (activeTool === 'measure' && isMeasuringRef.current && measureState) { + setMeasureState(updateMeasurement(measureState, worldPoint)); + } + }, + [handlePanMove, isPanningRef, getWorldPoint, activeTool, walls, openings, measureState, selectedIds, electricalItems, furnitureItems, dispatch], + ); + + const handleMouseMove = useCallback( + (e: Konva.KonvaEventObject) => { + // Always forward pan and item drag moves immediately for responsiveness + if (isPanningRef.current) { + handlePanMove(e); + return; + } + if (isDraggingItemRef.current) { + processMouseMove(e); + return; + } + + pendingMoveRef.current = e; + + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null; + const pending = pendingMoveRef.current; + if (pending) { + pendingMoveRef.current = null; + processMouseMove(pending); + } + }); + } + }, + [isPanningRef, handlePanMove, processMouseMove], + ); + + // Cancel pending RAF on unmount + useEffect(() => { + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + }; + }, []); + + // ── Mouse Up ── + const handleMouseUp = useCallback( + (e: Konva.KonvaEventObject) => { + handlePanEnd(); + + // Finish item drag + if (isDraggingItemRef.current) { + isDraggingItemRef.current = false; + dragItemStartRef.current = null; + } + + // Finish drag select + if (isDraggingSelectRef.current && dragStartRef.current) { + const worldPoint = getWorldPoint(e); + const rect = selectionRect(dragStartRef.current, worldPoint); + + // Only if the rectangle is non-trivial + if (rect.width > 0.01 || rect.height > 0.01) { + const ids = elementsInRect(rect, openings, walls, electricalItems, furnitureItems); + if (ids.size > 0) { + selectionDispatch({ type: 'SET_SELECTED', ids }); + } + } + + dragStartRef.current = null; + isDraggingSelectRef.current = false; + setDragRect(null); + } + + // Finish measurement + if (isMeasuringRef.current && measureState) { + const worldPoint = getWorldPoint(e); + const result = finishMeasurement(updateMeasurement(measureState, worldPoint)); + // Keep the measurement visible (don't clear it) + setMeasureState(result); + isMeasuringRef.current = false; + } + }, + [handlePanEnd, getWorldPoint, openings, walls, electricalItems, furnitureItems, selectionDispatch, measureState], + ); + + // ── Prevent context menu on canvas ── + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + const container = stage.container(); + const handler = (e: Event) => e.preventDefault(); + container.addEventListener('contextmenu', handler); + return () => container.removeEventListener('contextmenu', handler); + }, []); + + const cursor = TOOL_CURSORS[activeTool] ?? 'default'; + + return ( +
+ + {/* Layer 1: Grid + rulers */} + + + {/* Layer 2: Walls + room fill */} + {layerVisibility.walls && ( + + )} + + {/* Layer 3: Openings (doors + windows) */} + + + {/* Layer 4: Electrical */} + + + {/* Layer 5: Furniture */} + + + {/* Layer 6: Measurements */} + {layerVisibility.measurements && ( + + )} + + {/* Layer 7: Room labels */} + {layerVisibility.measurements && ( + + )} + + {/* Layer 8: Annotations */} + {layerVisibility.annotations && ( + selectElement(id)} + onDragEnd={(id, x, y) => { + const ann = annotations.find((a) => a.id === id); + if (ann) updateAnnotation({ ...ann, x, y }); + }} + onDoubleClick={(id) => { + const ann = annotations.find((a) => a.id === id); + if (!ann) return; + const newText = window.prompt(t('annotation.editPrompt'), ann.text); + if (newText != null && newText !== ann.text) { + updateAnnotation({ ...ann, text: newText }); + } + }} + /> + )} + + {/* Layer 9: Measure overlay */} + + + {/* Layer 10: Selection overlay */} + + + +
+ ); +} diff --git a/apps/client/src/components/editor/EditorToolbar.tsx b/apps/client/src/components/editor/EditorToolbar.tsx new file mode 100644 index 0000000..89ffef1 --- /dev/null +++ b/apps/client/src/components/editor/EditorToolbar.tsx @@ -0,0 +1,253 @@ +import { useTranslation } from 'react-i18next'; +import { useEditor } from './context/EditorContext'; +import { useUndoRedo } from './context/UndoRedoContext'; +import type { EditorToolType, AlignmentDirection } from './types'; +import styles from './editor-toolbar.module.css'; + +interface EditorToolbarProps { + readonly onSave: () => void; + readonly isSaving: boolean; + readonly isDirty?: boolean; + readonly isAutoSaving?: boolean; + readonly onExport?: () => void; + readonly onImport?: () => void; +} + +interface ToolDef { + readonly type: EditorToolType; + readonly labelKey: string; + readonly shortcut: string; + readonly icon: string; +} + +const TOOLS: readonly ToolDef[] = [ + { type: 'select', labelKey: 'toolbar.select', shortcut: 'V', icon: '\u25B3' }, + { type: 'door', labelKey: 'toolbar.door', shortcut: 'D', icon: '\u25A1' }, + { type: 'window', labelKey: 'toolbar.window', shortcut: 'W', icon: '\u2550' }, + { type: 'electrical', labelKey: 'toolbar.electrical', shortcut: 'E', icon: '\u26A1' }, + { type: 'furniture', labelKey: 'toolbar.furniture', shortcut: 'F', icon: '\u{1FA91}' }, + { type: 'measure', labelKey: 'toolbar.measure', shortcut: 'M', icon: '\u{1F4CF}' }, + { type: 'annotate', labelKey: 'toolbar.annotate', shortcut: 'T', icon: '\u{1D5A0}' }, +]; + +interface AlignDef { + readonly alignment: AlignmentDirection; + readonly label: string; + readonly titleKey: string; + readonly icon: string; +} + +const ALIGNMENT_BUTTONS: readonly AlignDef[] = [ + { alignment: 'left', label: 'L', titleKey: 'toolbar.alignLeft', icon: '\u2590' }, + { alignment: 'center-h', label: 'CH', titleKey: 'toolbar.alignCenterH', icon: '\u2503' }, + { alignment: 'right', label: 'R', titleKey: 'toolbar.alignRight', icon: '\u258C' }, + { alignment: 'top', label: 'T', titleKey: 'toolbar.alignTop', icon: '\u2580' }, + { alignment: 'center-v', label: 'CV', titleKey: 'toolbar.alignCenterV', icon: '\u2501' }, + { alignment: 'bottom', label: 'B', titleKey: 'toolbar.alignBottom', icon: '\u2584' }, + { alignment: 'distribute-h', label: 'DH', titleKey: 'toolbar.distributeH', icon: '\u2506' }, + { alignment: 'distribute-v', label: 'DV', titleKey: 'toolbar.distributeV', icon: '\u2504' }, +]; + +export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorToolbarProps) { + const { t } = useTranslation(); + const { state, setTool, setZoom, dispatch } = useEditor(); + const { undo, redo, canUndo, canRedo } = useUndoRedo(); + + const { activeTool, zoom, gridVisible, snapEnabled, layerVisibility } = state; + + const zoomPercent = Math.round((zoom / 100) * 100); + + return ( +
+ {/* Tool buttons */} +
+ {TOOLS.map((tool) => { + const label = t(tool.labelKey); + return ( + + ); + })} +
+ +
+ + {/* Undo / Redo */} +
+ + +
+ +
+ + {/* Zoom controls */} +
+ + {zoomPercent}% + +
+ +
+ + {/* Grid + Snap toggles */} +
+ + +
+ +
+ + {/* Layer visibility toggles */} +
+ + + + +
+ + {/* Alignment tools — visible when 2+ items selected */} + {state.selectedIds.size >= 2 && ( + <> +
+
+ {ALIGNMENT_BUTTONS.map((btn) => ( + + ))} +
+ + )} + + {/* Spacer */} +
+ + {/* Import + Export + Save buttons */} +
+ {onImport && ( + + )} + {onExport && ( + + )} + +
+
+ ); +} diff --git a/apps/client/src/components/editor/PropertiesPanel.tsx b/apps/client/src/components/editor/PropertiesPanel.tsx new file mode 100644 index 0000000..53c2ff6 --- /dev/null +++ b/apps/client/src/components/editor/PropertiesPanel.tsx @@ -0,0 +1,664 @@ +import { useMemo, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType } from '@house-plan-maker/shared'; +import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES } from '@house-plan-maker/shared'; +import { useEditor } from './context/EditorContext'; +import { useUndoRedo } from './context/UndoRedoContext'; +import { wallLength } from './utils/wallUtils'; +import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry'; +import { getElectricalVariant, ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical'; +import type { EditorCommand } from './types'; +import styles from './properties-panel.module.css'; + +export function PropertiesPanel() { + const { t } = useTranslation(); + const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall, addAnnotation } = useEditor(); + const { execute } = useUndoRedo(); + const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state; + + const roomArea = useMemo( + () => room.shape.length >= 3 ? polygonArea(room.shape) : 0, + [room.shape], + ); + const roomPerimeter = useMemo( + () => room.shape.length >= 2 ? polygonPerimeter(room.shape) : 0, + [room.shape], + ); + + // Find selected elements + const selected = useMemo(() => { + const items: { + type: 'wall' | 'opening' | 'electrical' | 'furniture'; + data: Wall | WallOpening | ElectricalItem | FurnitureItem; + }[] = []; + + for (const id of selectedIds) { + const wall = walls.find((w) => w.id === id); + if (wall) { + items.push({ type: 'wall', data: wall }); + continue; + } + const opening = openings.find((o) => o.id === id); + if (opening) { + items.push({ type: 'opening', data: opening }); + continue; + } + const elec = electricalItems.find((e) => e.id === id); + if (elec) { + items.push({ type: 'electrical', data: elec }); + continue; + } + const furn = furnitureItems.find((f) => f.id === id); + if (furn) { + items.push({ type: 'furniture', data: furn }); + } + } + + return items; + }, [selectedIds, walls, openings, electricalItems, furnitureItems]); + + if (selected.length === 0) { + return ( +
+
{t('properties.title')}
+
+

{t('properties.noSelection')}

+

{t('properties.selectHint')}

+
+
+
{t('properties.roomInfo')}
+ + {roomArea > 0 && ( + + )} + {roomPerimeter > 0 && ( + + )} + + + + label={t('properties.floorType')} + value={room.floorType} + options={FLOOR_TYPES.map((ft) => ({ + value: ft, + label: t(`floor.${ft}`), + }))} + onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { floorType: v } })} + /> +
+ {t('properties.wallColor')} + dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallColor: e.target.value } })} + style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }} + /> +
+ + +
+
+ ); + } + + if (selected.length > 1) { + return ( +
+
{t('properties.title')}
+
+

{t('properties.multipleSelected', { count: selected.length })}

+
+
+ ); + } + + const item = selected[0]; + + return ( +
+
{t('properties.title')}
+ {item.type === 'wall' && ( + { + const original = item.data as Wall; + const cmd: EditorCommand = { + description: 'Update wall thickness', + execute: () => updateWall(updated), + undo: () => updateWall(original), + }; + execute(cmd); + }} + /> + )} + {item.type === 'opening' && ( + { + const original = item.data as WallOpening; + const cmd: EditorCommand = { + description: `Update ${updated.type.toLowerCase()}`, + execute: () => updateOpening(updated), + undo: () => updateOpening(original), + }; + execute(cmd); + }} + /> + )} + {item.type === 'electrical' && ( + { + const original = item.data as ElectricalItem; + const cmd: EditorCommand = { + description: 'Update electrical item', + execute: () => updateElectrical(updated), + undo: () => updateElectrical(original), + }; + execute(cmd); + }} + /> + )} + {item.type === 'furniture' && ( + { + const original = item.data as FurnitureItem; + const cmd: EditorCommand = { + description: 'Update furniture item', + execute: () => updateFurniture(updated), + undo: () => updateFurniture(original), + }; + execute(cmd); + }} + /> + )} + {/* Add note button for any item */} + {(item.type === 'electrical' || item.type === 'furniture') && ( +
+ +
+ )} +
+ ); +} + +// ── Wall Properties ── + +interface WallPropertiesProps { + readonly wall: Wall; + readonly onUpdate: (wall: Wall) => void; +} + +function WallProperties({ wall, onUpdate }: WallPropertiesProps) { + const { t } = useTranslation(); + const len = wallLength(wall); + + const handleThicknessChange = useCallback( + (value: string) => { + const cm = parseFloat(value); + if (!isNaN(cm) && cm > 0 && cm <= 100) { + onUpdate({ ...wall, thickness: cm / 100 }); + } + }, + [wall, onUpdate], + ); + + return ( +
+
{t('properties.wall')}
+ + + + + + + +
+ ); +} + +// ── Opening Properties ── + +interface OpeningPropertiesProps { + readonly opening: WallOpening; + readonly walls: readonly Wall[]; + readonly onUpdate: (opening: WallOpening) => void; +} + +function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) { + const { t } = useTranslation(); + const wall = walls.find((w) => w.id === opening.wallId); + const wLen = wall ? wallLength(wall) : 0; + + const handleWidthChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num) && num > 0 && num < wLen) { + onUpdate({ ...opening, width: num }); + } + }, + [opening, onUpdate, wLen], + ); + + const handleHeightChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num) && num > 0) { + onUpdate({ ...opening, height: num }); + } + }, + [opening, onUpdate], + ); + + // Position displayed as left edge offset, stored as center + const displayPosition = Math.round((opening.positionAlongWall - opening.width / 2) * 1000) / 1000; + + const handlePositionChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num) && num >= 0) { + // Convert left edge offset back to center position + const centerPos = num + opening.width / 2; + if (centerPos <= wLen) { + onUpdate({ ...opening, positionAlongWall: centerPos }); + } + } + }, + [opening, onUpdate, wLen], + ); + + const handleElevationChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num) && num >= 0) { + onUpdate({ ...opening, elevationFromFloor: num }); + } + }, + [opening, onUpdate], + ); + + const handleOpenDirectionChange = useCallback( + (direction: DoorOpenDirection) => { + onUpdate({ ...opening, openDirection: direction }); + }, + [opening, onUpdate], + ); + + return ( +
+
+ {opening.type === 'DOOR' ? t('properties.door') : t('properties.window')} +
+ + + + {opening.type === 'DOOR' && ( + ({ + value: dir, + label: t(`properties.openDir.${dir}`), + }))} + onChange={handleOpenDirectionChange} + /> + )} + {opening.type === 'WINDOW' && ( + + )} + {wall && ( + + )} +
+ ); +} + +// ── Property Row Components ── + +interface PropertyRowProps { + readonly label: string; + readonly value: string; +} + +function PropertyRow({ label, value }: PropertyRowProps) { + return ( +
+ {label} + {value} +
+ ); +} + +interface EditablePropertyRowProps { + readonly label: string; + readonly value: string; + readonly unit?: string; + readonly onCommit: (value: string) => void; +} + +function EditablePropertyRow({ label, value, unit, onCommit }: EditablePropertyRowProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + + const handleBlur = useCallback(() => { + setEditing(false); + if (draft !== value) { + onCommit(draft); + } + }, [draft, value, onCommit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleBlur(); + } else if (e.key === 'Escape') { + setDraft(value); + setEditing(false); + } + }, + [handleBlur, value], + ); + + if (editing) { + return ( +
+ {label} +
+ setDraft(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + autoFocus + /> + {unit && {unit}} +
+
+ ); + } + + return ( +
+ {label} + +
+ ); +} + +// ── Select Property Row ── + +interface SelectPropertyRowProps { + readonly label: string; + readonly value: T; + readonly options: readonly { readonly value: T; readonly label: string }[]; + readonly onChange: (value: T) => void; +} + +function SelectPropertyRow({ label, value, options, onChange }: SelectPropertyRowProps) { + return ( +
+ {label} + +
+ ); +} + +// ── Electrical Properties ── + +interface ElectricalPropertiesProps { + readonly item: ElectricalItem; + readonly onUpdate: (item: ElectricalItem) => void; +} + +function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) { + const { t } = useTranslation(); + const variant = getElectricalVariant(item.metadata); + const def = ELECTRICAL_SYMBOL_DEFS.find( + (d) => d.type === item.type && (d.variant ?? 'single') === variant, + ); + + const isWallMounted = item.wallId !== null; + + const handleXChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num)) onUpdate({ ...item, x: num }); + }, + [item, onUpdate], + ); + + const handleYChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num)) onUpdate({ ...item, y: num }); + }, + [item, onUpdate], + ); + + const handleRotationChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 }); + }, + [item, onUpdate], + ); + + const handleElevationChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num) && num >= 0) { + onUpdate({ ...item, elevationFromFloor: num }); + } + }, + [item, onUpdate], + ); + + return ( +
+
+ {def?.label ?? item.type} +
+ + {variant !== 'single' && } + + + + {isWallMounted && ( + <> + + + + )} +
+ ); +} + +// ── Furniture Properties ── + +interface FurniturePropertiesProps { + readonly item: FurnitureItem; + readonly onUpdate: (item: FurnitureItem) => void; +} + +function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) { + const { t } = useTranslation(); + + const handleXChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num)) onUpdate({ ...item, x: num }); + }, + [item, onUpdate], + ); + + const handleYChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num)) onUpdate({ ...item, y: num }); + }, + [item, onUpdate], + ); + + const handleWidthChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num) && num > 0) onUpdate({ ...item, width: num }); + }, + [item, onUpdate], + ); + + const handleDepthChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num) && num > 0) onUpdate({ ...item, depth: num }); + }, + [item, onUpdate], + ); + + const handleHeightChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num) && num > 0) onUpdate({ ...item, height: num }); + }, + [item, onUpdate], + ); + + const handleElevationChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num) && num >= 0) onUpdate({ ...item, elevationFromFloor: num }); + }, + [item, onUpdate], + ); + + const handleRotationChange = useCallback( + (value: string) => { + const num = parseFloat(value); + if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 }); + }, + [item, onUpdate], + ); + + return ( +
+
+ {item.label ?? item.type} +
+ + + + + + + + + {item.type === 'TV' && ( +
+ {t('properties.stand')} + +
+ )} +
+ ); +} + +function formatM(meters: number): string { + return `${Math.round(meters * 1000) / 1000}m`; +} + +function formatCm(meters: number): string { + return `${Math.round(meters * 100)}cm`; +} diff --git a/apps/client/src/components/editor/RoomEditorLayout.tsx b/apps/client/src/components/editor/RoomEditorLayout.tsx new file mode 100644 index 0000000..7f19016 --- /dev/null +++ b/apps/client/src/components/editor/RoomEditorLayout.tsx @@ -0,0 +1,580 @@ +import { useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react'; +import { useBlocker } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import type Konva from 'konva'; +import { useEditor } from './context/EditorContext'; +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; +import { boundingBox } from './utils/geometry'; +import { EditorCanvas } from './EditorCanvas'; +import { EditorToolbar } from './EditorToolbar'; +import { PropertiesPanel } from './PropertiesPanel'; +import { ElectricalPalette } from './panels/ElectricalPalette'; +import { FurniturePalette } from './panels/FurniturePalette'; +import { CableLengthStatus } from './panels/CableLengthStatus'; +import { ProjectionPanel } from './projection/ProjectionPanel'; +import { ExportDialog } from './export/ExportDialog'; +import { importRoomFromJson } from './export/roomFormat'; + +const Room3DView = lazy(() => + import('./three/Room3DView').then((m) => ({ default: m.Room3DView })), +); +import { + getRoomFull, + bulkUpdateWalls, + batchSyncOpenings, + batchSyncElectrical, + batchSyncFurniture, +} from '../../api/client'; +import type { + CreateWallOpeningDto, + UpdateWallOpeningDto, + CreateElectricalItemDto, + UpdateElectricalItemDto, + CreateFurnitureItemDto, + UpdateFurnitureItemDto, +} from '@house-plan-maker/shared'; +import styles from './room-editor-layout.module.css'; + +const AUTO_SAVE_DELAY_MS = 5000; + +interface RoomEditorLayoutProps { + readonly roomId: string; +} + +export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { + const { t } = useTranslation(); + const { state, dispatch } = useEditor(); + const [isSaving, setIsSaving] = useState(false); + const [isAutoSaving, setIsAutoSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + type ViewMode = '2d' | '3d' | 'projections'; + const [viewMode, setViewMode] = useState('2d'); + const [showExport, setShowExport] = useState(false); + const canvasContainerRef = useRef(null); + const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 }); + + // ── Dirty tracking ── + const [isDirty, setIsDirty] = useState(false); + const lastSavedRef = useRef({ + walls: state.walls, + openings: state.openings, + electricalItems: state.electricalItems, + furnitureItems: state.furnitureItems, + }); + + // Mark dirty when state diverges from last saved snapshot + useEffect(() => { + const saved = lastSavedRef.current; + const dirty = + state.walls !== saved.walls || + state.openings !== saved.openings || + state.electricalItems !== saved.electricalItems || + state.furnitureItems !== saved.furnitureItems; + setIsDirty(dirty); + }, [state.walls, state.openings, state.electricalItems, state.furnitureItems]); + + // Warn on browser close / refresh + useEffect(() => { + if (!isDirty) return; + + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [isDirty]); + + // Block in-app navigation via react-router + const blocker = useBlocker(isDirty); + + useEffect(() => { + if (blocker.state === 'blocked') { + const leave = window.confirm( + t('editor.unsavedChanges'), + ); + if (leave) { + blocker.proceed(); + } else { + blocker.reset(); + } + } + }, [blocker]); + + // ── Export refs ── + const mainStageRef = useRef(null); + const projectionStageMapRef = useRef>(new Map()); + const threeCanvasRef = useRef(null); + + const handleMainStageRef = useCallback((stage: Konva.Stage | null) => { + mainStageRef.current = stage; + }, []); + + const handleProjectionStageRef = useCallback((wallId: string, stage: Konva.Stage | null) => { + if (stage) { + projectionStageMapRef.current.set(wallId, stage); + } else { + projectionStageMapRef.current.delete(wallId); + } + }, []); + + // ── Resize observer for canvas ── + useEffect(() => { + const container = canvasContainerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setCanvasSize({ width: Math.floor(width), height: Math.floor(height) }); + } + }); + + observer.observe(container); + + // Initial size + const rect = container.getBoundingClientRect(); + setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) }); + + return () => observer.disconnect(); + }, []); + + // ── Center room in canvas on first mount ── + const hasCenteredRef = useRef(false); + useEffect(() => { + if (hasCenteredRef.current) return; + if (canvasSize.width <= 100 || canvasSize.height <= 100) return; + if (state.room.shape.length === 0) return; + + const bbox = boundingBox(state.room.shape); + const roomW = bbox.maxX - bbox.minX; + const roomH = bbox.maxY - bbox.minY; + if (roomW <= 0 || roomH <= 0) return; + + // Fit room in canvas with some padding + const padding = 80; + const scaleX = (canvasSize.width - padding * 2) / roomW; + const scaleY = (canvasSize.height - padding * 2) / roomH; + const fitZoom = Math.min(scaleX, scaleY, 300); + + const centerX = (bbox.minX + bbox.maxX) / 2; + const centerY = (bbox.minY + bbox.maxY) / 2; + const panX = canvasSize.width / 2 - centerX * fitZoom; + const panY = canvasSize.height / 2 - centerY * fitZoom; + + dispatch({ type: 'SET_ZOOM', zoom: fitZoom }); + dispatch({ type: 'SET_PAN_OFFSET', offset: { x: panX, y: panY } }); + hasCenteredRef.current = true; + }, [canvasSize, state.room.shape, dispatch]); + + // ── Re-measure canvas when switching back to 2D view ── + useEffect(() => { + if (viewMode !== '2d') return; + // Use requestAnimationFrame to measure after the style change is applied + const rafId = requestAnimationFrame(() => { + const container = canvasContainerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) }); + } + }); + return () => cancelAnimationFrame(rafId); + }, [viewMode]); + + // ── Save handler (batch-optimized) ── + const isSavingRef = useRef(false); + + const handleSave = useCallback(async () => { + if (isSavingRef.current) return; + isSavingRef.current = true; + setIsSaving(true); + setSaveError(null); + + try { + // 1. Save walls first (bulk replace) to get server-assigned wall IDs + const wallDtos = state.walls.map((w) => ({ + startX: w.startX, + startY: w.startY, + endX: w.endX, + endY: w.endY, + thickness: w.thickness, + direction: w.direction, + })); + const serverWalls = await bulkUpdateWalls(roomId, wallDtos); + + // Build a map from local wall to server wall by matching coordinates + const wallIdMap = new Map(); + for (const localWall of state.walls) { + const match = serverWalls.find( + (sw) => + Math.abs(sw.startX - localWall.startX) < 0.001 && + Math.abs(sw.startY - localWall.startY) < 0.001 && + Math.abs(sw.endX - localWall.endX) < 0.001 && + Math.abs(sw.endY - localWall.endY) < 0.001, + ); + if (match) { + wallIdMap.set(localWall.id, match.id); + } + } + + // 2. Fetch current server state once for diff computation + const freshRoom = await getRoomFull(roomId); + + // 3. Compute openings — since bulkUpdateWalls CASCADE-deletes all openings, + // we must re-create ALL openings with the new server wall IDs + const openingsCreate: CreateWallOpeningDto[] = []; + const openingsUpdate: { id: string; data: UpdateWallOpeningDto }[] = []; + const openingsDelete: string[] = []; + + for (const opening of state.openings) { + const serverWallId = wallIdMap.get(opening.wallId) ?? opening.wallId; + openingsCreate.push({ + wallId: serverWallId, + type: opening.type, + positionAlongWall: opening.positionAlongWall, + width: opening.width, + height: opening.height, + elevationFromFloor: opening.elevationFromFloor, + openDirection: opening.openDirection, + }); + } + // No updates or deletes needed — cascade already removed all server openings + + // 4. Electrical items — since wall IDs changed after bulk replace, + // delete all existing and re-create with correct wall IDs + const elecCreate: CreateElectricalItemDto[] = []; + const elecUpdate: { id: string; data: UpdateElectricalItemDto }[] = []; + const elecDelete: string[] = freshRoom.electricalItems.map((e) => e.id); + + for (const elec of state.electricalItems) { + const serverWallId = elec.wallId ? (wallIdMap.get(elec.wallId) ?? elec.wallId) : null; + elecCreate.push({ + type: elec.type, + x: elec.x, + y: elec.y, + wallId: serverWallId, + elevationFromFloor: elec.elevationFromFloor, + rotation: elec.rotation, + metadata: elec.metadata, + }); + } + + // 5. Compute diffs for furniture + const serverFurnIds = new Set(freshRoom.furnitureItems.map((f) => f.id)); + const localFurnIds = new Set(state.furnitureItems.map((f) => f.id)); + + const furnCreate: CreateFurnitureItemDto[] = []; + const furnUpdate: { id: string; data: UpdateFurnitureItemDto }[] = []; + const furnDelete: string[] = []; + + for (const furn of state.furnitureItems) { + if (furn.id.startsWith('local-')) { + furnCreate.push({ + type: furn.type, + x: furn.x, + y: furn.y, + width: furn.width, + depth: furn.depth, + height: furn.height, + rotation: furn.rotation, + elevationFromFloor: furn.elevationFromFloor, + label: furn.label, + }); + } else if (serverFurnIds.has(furn.id)) { + const serverFurn = freshRoom.furnitureItems.find((f) => f.id === furn.id); + if (serverFurn) { + const hasChanges = + serverFurn.x !== furn.x || + serverFurn.y !== furn.y || + serverFurn.width !== furn.width || + serverFurn.depth !== furn.depth || + serverFurn.height !== furn.height || + serverFurn.rotation !== furn.rotation || + serverFurn.elevationFromFloor !== furn.elevationFromFloor || + serverFurn.label !== furn.label; + if (hasChanges) { + furnUpdate.push({ + id: furn.id, + data: { + type: furn.type, + x: furn.x, + y: furn.y, + width: furn.width, + depth: furn.depth, + height: furn.height, + rotation: furn.rotation, + elevationFromFloor: furn.elevationFromFloor, + label: furn.label, + }, + }); + } + } + } + } + + for (const serverFurn of freshRoom.furnitureItems) { + if (!localFurnIds.has(serverFurn.id)) { + furnDelete.push(serverFurn.id); + } + } + + // 6. Execute all 3 batch calls in parallel — responses contain final server state + const [syncedOpenings, syncedElectrical, syncedFurniture] = await Promise.all([ + batchSyncOpenings(roomId, { + create: openingsCreate, + update: openingsUpdate, + delete: openingsDelete, + }), + batchSyncElectrical(roomId, { + create: elecCreate, + update: elecUpdate, + delete: elecDelete, + }), + batchSyncFurniture(roomId, { + create: furnCreate, + update: furnUpdate, + delete: furnDelete, + }), + ]); + + // 7. Sync state with server-assigned IDs (single dispatch, no flicker) + dispatch({ + type: 'SYNC_SAVE', + walls: serverWalls, + openings: syncedOpenings, + electricalItems: syncedElectrical, + furnitureItems: syncedFurniture, + }); + + // Mark state as clean after successful save + lastSavedRef.current = { + walls: serverWalls, + openings: syncedOpenings, + electricalItems: syncedElectrical, + furnitureItems: syncedFurniture, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('editor.error.load'); + setSaveError(message); + } finally { + setIsSaving(false); + isSavingRef.current = false; + } + }, [roomId, state.walls, state.openings, state.electricalItems, state.furnitureItems, dispatch]); + + // ── Auto-save with ref-based debounce ── + const autoSaveTimerRef = useRef | null>(null); + + useEffect(() => { + // Clear any existing timer when dirty state or saving state changes + if (autoSaveTimerRef.current !== null) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + + // Only start the timer if dirty and not currently saving + if (!isDirty || isSavingRef.current) return; + + autoSaveTimerRef.current = setTimeout(() => { + autoSaveTimerRef.current = null; + // Guard: don't auto-save if a manual save is in progress + if (isSavingRef.current) return; + setIsAutoSaving(true); + handleSave().finally(() => setIsAutoSaving(false)); + }, AUTO_SAVE_DELAY_MS); + + return () => { + if (autoSaveTimerRef.current !== null) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + }; + }, [isDirty, handleSave]); + + // ── Keyboard shortcuts ── + useKeyboardShortcuts({ onSave: handleSave }); + + // ── Import handler ── + const [importError, setImportError] = useState(null); + const fileInputRef = useRef(null); + + const handleImportClick = useCallback(() => { + setImportError(null); + fileInputRef.current?.click(); + }, []); + + const handleImportFile = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Reset the input so the same file can be re-selected + event.target.value = ''; + + const reader = new FileReader(); + reader.onload = () => { + try { + const text = reader.result as string; + const imported = importRoomFromJson(text); + + const confirmed = window.confirm( + `Import room "${imported.room.name}"? This will replace all current walls, openings, electrical items, and furniture.`, + ); + if (!confirmed) return; + + dispatch({ + type: 'IMPORT_ROOM', + room: imported.room, + walls: imported.walls, + openings: imported.openings, + electricalItems: imported.electricalItems, + furnitureItems: imported.furnitureItems, + }); + setImportError(null); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to import file'; + setImportError(message); + } + }; + reader.onerror = () => { + setImportError('Failed to read the selected file.'); + }; + reader.readAsText(file); + }, + [dispatch], + ); + + return ( +
+ {/* Toolbar */} + {/* Hidden file input for JSON import */} + + + setShowExport(true)} + onImport={handleImportClick} + /> + + {saveError && ( +
+ {t('editor.saveFailed', { error: saveError })} +
+ )} + + {importError && ( +
+ {t('editor.importFailed', { error: importError })} +
+ )} + + {/* Main area: palette + canvas + properties panel */} +
+
+ {/* Overlay palettes (float over canvas, don't affect layout) */} + {state.activeTool === 'electrical' && ( + + dispatch({ type: 'SET_ELECTRICAL_INDEX', index }) + } + /> + )} + {state.activeTool === 'furniture' && ( + + dispatch({ type: 'SET_FURNITURE_INDEX', index }) + } + /> + )} + {/* View mode toggle: 2D / 3D / Projections */} +
+ + + +
+ + {viewMode === '3d' && ( +
{ + // Grab the R3F canvas element for 3D export + if (el) { + const canvas = el.querySelector('canvas'); + threeCanvasRef.current = canvas; + } else { + threeCanvasRef.current = null; + } + }} + > + {t('editor.loading3D')}
}> + + +
+ )} + +
+ +
+ {viewMode === '2d' && ( + + )} + + {/* ProjectionPanel always mounted for export, hidden when not active */} +
+ +
+
+ +
+ + {/* Export Dialog */} + setShowExport(false)} + mainStageRef={mainStageRef} + projectionStageRefs={projectionStageMapRef} + threeCanvasRef={threeCanvasRef} + is3DView={viewMode === '3d'} + viewMode={viewMode} + /> +
+ ); +} diff --git a/apps/client/src/components/editor/context/EditorContext.tsx b/apps/client/src/components/editor/context/EditorContext.tsx new file mode 100644 index 0000000..2aeab29 --- /dev/null +++ b/apps/client/src/components/editor/context/EditorContext.tsx @@ -0,0 +1,879 @@ +import { + createContext, + useContext, + useReducer, + useCallback, + useMemo, + useRef, + type ReactNode, +} from 'react'; +import type { + RoomFull, + Wall, + WallOpening, + ElectricalItem, + FurnitureItem, + Point, + Annotation, +} from '@house-plan-maker/shared'; +import type { EditorState, EditorAction, EditorToolType, LayerVisibility, PastePayload } from '../types'; +import { DEFAULT_ZOOM, DEFAULT_GRID_SIZE } from '../types'; +import { wallsFromShape } from '../utils/wallUtils'; +import { generateLocalId } from '../utils/geometry'; + +// ── Reducer ── + +function assignWallDirection(w: { startX: number; startY: number; endX: number; endY: number }, roomWidth: number, roomHeight: number): Wall['direction'] { + const dx = w.endX - w.startX; + const dy = w.endY - w.startY; + const isHorizontal = Math.abs(dx) > Math.abs(dy); + if (isHorizontal) { + return w.startY < roomHeight / 2 ? 'NORTH' : 'SOUTH'; + } + return w.startX < roomWidth / 2 ? 'WEST' : 'EAST'; +} + +function generateWallsFromShape(room: RoomFull): readonly Wall[] { + return wallsFromShape(room.shape).map((w) => ({ + ...w, + id: generateLocalId(), + roomId: room.id, + direction: assignWallDirection(w, room.width ?? 0, room.height ?? 0), + })); +} + +function wallsMatchShape(existingWalls: readonly Wall[], shape: readonly Point[]): boolean { + const shapeWalls = wallsFromShape(shape); + if (existingWalls.length !== shapeWalls.length) return false; + return shapeWalls.every((sw, i) => { + const ew = existingWalls[i]; + return ew && + Math.abs(ew.startX - sw.startX) < 0.001 && + Math.abs(ew.startY - sw.startY) < 0.001 && + Math.abs(ew.endX - sw.endX) < 0.001 && + Math.abs(ew.endY - sw.endY) < 0.001; + }); +} + +function createInitialState(room: RoomFull): EditorState { + // Auto-generate walls if none exist or if they don't match the room shape + const existingMatch = room.walls.length > 0 && wallsMatchShape(room.walls, room.shape); + const walls = existingMatch ? room.walls : generateWallsFromShape(room); + return { + room, + walls, + openings: existingMatch ? room.openings : [], + electricalItems: room.electricalItems, + furnitureItems: room.furnitureItems, + selectedIds: new Set(), + activeTool: 'select', + zoom: DEFAULT_ZOOM, + panOffset: { x: 0, y: 0 }, + gridSize: DEFAULT_GRID_SIZE, + gridVisible: true, + snapEnabled: true, + snapGranularity: DEFAULT_GRID_SIZE, + layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true }, + selectedElectricalIndex: null, + selectedFurnitureIndex: null, + annotations: [], + }; +} + +function editorReducer(state: EditorState, action: EditorAction): EditorState { + switch (action.type) { + case 'SET_ROOM': { + const room = action.room; + const existingMatch = room.walls.length > 0 && wallsMatchShape(room.walls, room.shape); + const walls = existingMatch ? room.walls : generateWallsFromShape(room); + return { + ...state, + room, + walls, + openings: existingMatch ? room.openings : [], + electricalItems: room.electricalItems, + furnitureItems: room.furnitureItems, + }; + } + case 'UPDATE_ROOM_PROPS': + return { ...state, room: { ...state.room, ...action.props } }; + case 'SET_WALLS': + return { ...state, walls: action.walls }; + case 'UPDATE_WALL': + return { + ...state, + walls: state.walls.map((w) => + w.id === action.wall.id ? action.wall : w, + ), + }; + case 'ADD_OPENING': + return { ...state, openings: [...state.openings, action.opening] }; + case 'UPDATE_OPENING': + return { + ...state, + openings: state.openings.map((o) => + o.id === action.opening.id ? action.opening : o, + ), + }; + case 'REMOVE_OPENING': + return { + ...state, + openings: state.openings.filter((o) => o.id !== action.id), + selectedIds: removeFromSet(state.selectedIds, action.id), + }; + case 'ADD_ELECTRICAL': + return { + ...state, + electricalItems: [...state.electricalItems, action.item], + }; + case 'UPDATE_ELECTRICAL': + return { + ...state, + electricalItems: state.electricalItems.map((i) => + i.id === action.item.id ? action.item : i, + ), + }; + case 'REMOVE_ELECTRICAL': + return { + ...state, + electricalItems: state.electricalItems.filter((i) => i.id !== action.id), + selectedIds: removeFromSet(state.selectedIds, action.id), + }; + case 'ADD_FURNITURE': + return { + ...state, + furnitureItems: [...state.furnitureItems, action.item], + }; + case 'UPDATE_FURNITURE': + return { + ...state, + furnitureItems: state.furnitureItems.map((i) => + i.id === action.item.id ? action.item : i, + ), + }; + case 'REMOVE_FURNITURE': + return { + ...state, + furnitureItems: state.furnitureItems.filter((i) => i.id !== action.id), + selectedIds: removeFromSet(state.selectedIds, action.id), + }; + case 'SET_SELECTED': + return { ...state, selectedIds: action.ids }; + case 'ADD_TO_SELECTION': + return { + ...state, + selectedIds: addToSet(state.selectedIds, action.id), + }; + case 'REMOVE_FROM_SELECTION': + return { + ...state, + selectedIds: removeFromSet(state.selectedIds, action.id), + }; + case 'CLEAR_SELECTION': + return { ...state, selectedIds: new Set() }; + case 'SET_TOOL': + return { ...state, activeTool: action.tool, selectedIds: new Set() }; + case 'SET_ZOOM': + return { ...state, zoom: action.zoom }; + case 'SET_PAN_OFFSET': + return { ...state, panOffset: action.offset }; + case 'SET_GRID_SIZE': + return { ...state, gridSize: action.gridSize }; + case 'TOGGLE_GRID': + return { ...state, gridVisible: !state.gridVisible }; + case 'TOGGLE_SNAP': + return { ...state, snapEnabled: !state.snapEnabled }; + case 'SET_SNAP_GRANULARITY': + return { ...state, snapGranularity: action.granularity }; + case 'TOGGLE_LAYER': + return { + ...state, + layerVisibility: { + ...state.layerVisibility, + [action.layer]: !state.layerVisibility[action.layer], + }, + }; + case 'SET_ELECTRICAL_INDEX': + return { ...state, selectedElectricalIndex: action.index }; + case 'SET_FURNITURE_INDEX': + return { ...state, selectedFurnitureIndex: action.index }; + case 'DELETE_SELECTED': { + return { + ...state, + openings: state.openings.filter((o) => !state.selectedIds.has(o.id)), + electricalItems: state.electricalItems.filter((e) => !state.selectedIds.has(e.id)), + furnitureItems: state.furnitureItems.filter((f) => !state.selectedIds.has(f.id)), + annotations: state.annotations.filter((a) => !state.selectedIds.has(a.id)), + selectedIds: new Set(), + }; + } + case 'SELECT_ALL': { + const allIds = new Set(); + for (const o of state.openings) allIds.add(o.id); + for (const e of state.electricalItems) allIds.add(e.id); + for (const f of state.furnitureItems) allIds.add(f.id); + for (const a of state.annotations) allIds.add(a.id); + return { ...state, selectedIds: allIds }; + } + // ── Clipboard ── + case 'COPY_SELECTED': + // No state change — clipboard is handled via ref in the provider + return state; + case 'PASTE_CLIPBOARD': { + const { items } = action; + return { + ...state, + openings: [...state.openings, ...items.openings], + electricalItems: [...state.electricalItems, ...items.electricalItems], + furnitureItems: [...state.furnitureItems, ...items.furnitureItems], + annotations: [...state.annotations, ...items.annotations], + selectedIds: items.newSelectedIds, + }; + } + // ── Alignment ── + case 'ALIGN_SELECTED': { + if (state.selectedIds.size < 2) return state; + return applyAlignment(state, action.alignment); + } + // ── Annotations ── + case 'ADD_ANNOTATION': + return { ...state, annotations: [...state.annotations, action.annotation] }; + case 'UPDATE_ANNOTATION': + return { + ...state, + annotations: state.annotations.map((a) => + a.id === action.annotation.id ? action.annotation : a, + ), + }; + case 'REMOVE_ANNOTATION': + return { + ...state, + annotations: state.annotations.filter((a) => a.id !== action.id), + selectedIds: removeFromSet(state.selectedIds, action.id), + }; + // ── Import ── + case 'SYNC_SAVE': { + // Build set of all new IDs to prune stale selections + const newIds = new Set(); + for (const w of action.walls) newIds.add(w.id); + for (const o of action.openings) newIds.add(o.id); + for (const e of action.electricalItems) newIds.add(e.id); + for (const f of action.furnitureItems) newIds.add(f.id); + // Keep only selected IDs that still exist in the new data + const prunedSelection = new Set(); + for (const id of state.selectedIds) { + if (newIds.has(id)) prunedSelection.add(id); + } + return { + ...state, + walls: action.walls, + openings: action.openings, + electricalItems: action.electricalItems, + furnitureItems: action.furnitureItems, + selectedIds: prunedSelection, + }; + } + case 'IMPORT_ROOM': + return { + ...state, + room: { + ...state.room, + name: action.room.name, + shape: action.room.shape, + wallHeight: action.room.wallHeight, + plinthHeight: action.room.plinthHeight, + plinthThickness: action.room.plinthThickness, + }, + walls: action.walls, + openings: action.openings, + electricalItems: action.electricalItems, + furnitureItems: action.furnitureItems, + annotations: [], + selectedIds: new Set(), + }; + default: + return state; + } +} + +function addToSet(set: ReadonlySet, id: string): ReadonlySet { + const next = new Set(set); + next.add(id); + return next; +} + +function removeFromSet(set: ReadonlySet, id: string): ReadonlySet { + if (!set.has(id)) return set; + const next = new Set(set); + next.delete(id); + return next; +} + +// ── Alignment helpers ── + +interface PositionedItem { + readonly id: string; + readonly x: number; + readonly y: number; +} + +function getSelectedPositionedItems(state: EditorState): readonly PositionedItem[] { + const items: PositionedItem[] = []; + for (const id of state.selectedIds) { + const elec = state.electricalItems.find((e) => e.id === id); + if (elec) { items.push({ id: elec.id, x: elec.x, y: elec.y }); continue; } + const furn = state.furnitureItems.find((f) => f.id === id); + if (furn) { items.push({ id: furn.id, x: furn.x, y: furn.y }); continue; } + const ann = state.annotations.find((a) => a.id === id); + if (ann) { items.push({ id: ann.id, x: ann.x, y: ann.y }); continue; } + } + return items; +} + +function applyAlignment(state: EditorState, alignment: string): EditorState { + const items = getSelectedPositionedItems(state); + if (items.length < 2) return state; + + const offsets = new Map(); + + switch (alignment) { + case 'left': { + const minX = Math.min(...items.map((i) => i.x)); + for (const item of items) offsets.set(item.id, { dx: minX - item.x, dy: 0 }); + break; + } + case 'right': { + const maxX = Math.max(...items.map((i) => i.x)); + for (const item of items) offsets.set(item.id, { dx: maxX - item.x, dy: 0 }); + break; + } + case 'top': { + const minY = Math.min(...items.map((i) => i.y)); + for (const item of items) offsets.set(item.id, { dx: 0, dy: minY - item.y }); + break; + } + case 'bottom': { + const maxY = Math.max(...items.map((i) => i.y)); + for (const item of items) offsets.set(item.id, { dx: 0, dy: maxY - item.y }); + break; + } + case 'center-h': { + const avgX = items.reduce((s, i) => s + i.x, 0) / items.length; + for (const item of items) offsets.set(item.id, { dx: avgX - item.x, dy: 0 }); + break; + } + case 'center-v': { + const avgY = items.reduce((s, i) => s + i.y, 0) / items.length; + for (const item of items) offsets.set(item.id, { dx: 0, dy: avgY - item.y }); + break; + } + case 'distribute-h': { + const sorted = [...items].sort((a, b) => a.x - b.x); + if (sorted.length < 3) break; + const minX = sorted[0].x; + const maxX = sorted[sorted.length - 1].x; + const step = (maxX - minX) / (sorted.length - 1); + for (let i = 0; i < sorted.length; i++) { + offsets.set(sorted[i].id, { dx: minX + step * i - sorted[i].x, dy: 0 }); + } + break; + } + case 'distribute-v': { + const sorted = [...items].sort((a, b) => a.y - b.y); + if (sorted.length < 3) break; + const minY = sorted[0].y; + const maxY = sorted[sorted.length - 1].y; + const step = (maxY - minY) / (sorted.length - 1); + for (let i = 0; i < sorted.length; i++) { + offsets.set(sorted[i].id, { dx: 0, dy: minY + step * i - sorted[i].y }); + } + break; + } + } + + if (offsets.size === 0) return state; + + const applyOffset = ( + list: readonly T[], + ): readonly T[] => + list.map((item) => { + const o = offsets.get(item.id); + if (!o || (o.dx === 0 && o.dy === 0)) return item; + return { ...item, x: item.x + o.dx, y: item.y + o.dy }; + }); + + return { + ...state, + electricalItems: applyOffset(state.electricalItems), + furnitureItems: applyOffset(state.furnitureItems), + annotations: applyOffset(state.annotations), + }; +} + +// ── ZoomPan Context ── + +interface ZoomPanContextValue { + readonly zoom: number; + readonly panOffset: Point; + setZoom(zoom: number): void; + setPanOffset(offset: Point): void; +} + +const ZoomPanContext = createContext(null); + +// ── Selection Context ── + +interface SelectionContextValue { + readonly selectedIds: ReadonlySet; + readonly activeTool: EditorToolType; + readonly dispatch: (action: EditorAction) => void; + setTool(tool: EditorToolType): void; + selectElement(id: string, addToSelection?: boolean): void; + clearSelection(): void; + deleteSelected(): void; + selectAll(): void; +} + +const SelectionContext = createContext(null); + +// ── SceneData Context ── + +interface SceneDataContextValue { + readonly room: RoomFull; + readonly walls: readonly Wall[]; + readonly openings: readonly WallOpening[]; + readonly electricalItems: readonly ElectricalItem[]; + readonly furnitureItems: readonly FurnitureItem[]; + readonly annotations: readonly Annotation[]; + readonly gridSize: number; + readonly gridVisible: boolean; + readonly snapEnabled: boolean; + readonly snapGranularity: number; + readonly layerVisibility: LayerVisibility; + readonly selectedElectricalIndex: number | null; + readonly selectedFurnitureIndex: number | null; + readonly dispatch: (action: EditorAction) => void; + setWalls(walls: readonly Wall[]): void; + updateWall(wall: Wall): void; + addOpening(opening: WallOpening): void; + updateOpening(opening: WallOpening): void; + removeOpening(id: string): void; + addElectrical(item: ElectricalItem): void; + updateElectrical(item: ElectricalItem): void; + removeElectrical(id: string): void; + addFurniture(item: FurnitureItem): void; + updateFurniture(item: FurnitureItem): void; + removeFurniture(id: string): void; + addAnnotation(annotation: Annotation): void; + updateAnnotation(annotation: Annotation): void; + removeAnnotation(id: string): void; + copySelected(): void; + pasteClipboard(): void; +} + +const SceneDataContext = createContext(null); + +// ── Legacy combined context (for useEditor backward compat) ── + +interface EditorContextValue { + readonly state: EditorState; + readonly dispatch: (action: EditorAction) => void; + setTool(tool: EditorToolType): void; + setZoom(zoom: number): void; + setPanOffset(offset: Point): void; + selectElement(id: string, addToSelection?: boolean): void; + clearSelection(): void; + deleteSelected(): void; + selectAll(): void; + addOpening(opening: WallOpening): void; + updateOpening(opening: WallOpening): void; + removeOpening(id: string): void; + setWalls(walls: readonly Wall[]): void; + updateWall(wall: Wall): void; + addElectrical(item: ElectricalItem): void; + updateElectrical(item: ElectricalItem): void; + removeElectrical(id: string): void; + addFurniture(item: FurnitureItem): void; + updateFurniture(item: FurnitureItem): void; + removeFurniture(id: string): void; + addAnnotation(annotation: Annotation): void; + updateAnnotation(annotation: Annotation): void; + removeAnnotation(id: string): void; + copySelected(): void; + pasteClipboard(): void; +} + +const EditorContext = createContext(null); + +// ── Provider ── + +interface EditorProviderProps { + readonly room: RoomFull; + readonly children: ReactNode; +} + +export function EditorProvider({ room, children }: EditorProviderProps) { + const [state, dispatch] = useReducer(editorReducer, room, createInitialState); + + // ── Stable callbacks (dispatch never changes) ── + + const setTool = useCallback( + (tool: EditorToolType) => dispatch({ type: 'SET_TOOL', tool }), + [], + ); + + const setZoom = useCallback( + (zoom: number) => dispatch({ type: 'SET_ZOOM', zoom }), + [], + ); + + const setPanOffset = useCallback( + (offset: Point) => dispatch({ type: 'SET_PAN_OFFSET', offset }), + [], + ); + + const selectElement = useCallback( + (id: string, addToSelection = false) => { + if (addToSelection) { + dispatch({ type: 'ADD_TO_SELECTION', id }); + } else { + dispatch({ type: 'SET_SELECTED', ids: new Set([id]) }); + } + }, + [], + ); + + const clearSelection = useCallback( + () => dispatch({ type: 'CLEAR_SELECTION' }), + [], + ); + + // Task 6: dispatch-only callbacks — no closure over state arrays + const deleteSelected = useCallback( + () => dispatch({ type: 'DELETE_SELECTED' }), + [], + ); + + const selectAll = useCallback( + () => dispatch({ type: 'SELECT_ALL' }), + [], + ); + + const addOpening = useCallback( + (opening: WallOpening) => dispatch({ type: 'ADD_OPENING', opening }), + [], + ); + const updateOpening = useCallback( + (opening: WallOpening) => dispatch({ type: 'UPDATE_OPENING', opening }), + [], + ); + const removeOpening = useCallback( + (id: string) => dispatch({ type: 'REMOVE_OPENING', id }), + [], + ); + const setWalls = useCallback( + (walls: readonly Wall[]) => dispatch({ type: 'SET_WALLS', walls }), + [], + ); + const updateWall = useCallback( + (wall: Wall) => dispatch({ type: 'UPDATE_WALL', wall }), + [], + ); + const addElectrical = useCallback( + (item: ElectricalItem) => dispatch({ type: 'ADD_ELECTRICAL', item }), + [], + ); + const updateElectrical = useCallback( + (item: ElectricalItem) => dispatch({ type: 'UPDATE_ELECTRICAL', item }), + [], + ); + const removeElectrical = useCallback( + (id: string) => dispatch({ type: 'REMOVE_ELECTRICAL', id }), + [], + ); + const addFurniture = useCallback( + (item: FurnitureItem) => dispatch({ type: 'ADD_FURNITURE', item }), + [], + ); + const updateFurniture = useCallback( + (item: FurnitureItem) => dispatch({ type: 'UPDATE_FURNITURE', item }), + [], + ); + const removeFurniture = useCallback( + (id: string) => dispatch({ type: 'REMOVE_FURNITURE', id }), + [], + ); + const addAnnotation = useCallback( + (annotation: Annotation) => dispatch({ type: 'ADD_ANNOTATION', annotation }), + [], + ); + const updateAnnotation = useCallback( + (annotation: Annotation) => dispatch({ type: 'UPDATE_ANNOTATION', annotation }), + [], + ); + const removeAnnotation = useCallback( + (id: string) => dispatch({ type: 'REMOVE_ANNOTATION', id }), + [], + ); + + // ── Clipboard (ref-based so copy reads current state without closures) ── + const clipboardRef = useRef<{ + openings: readonly WallOpening[]; + electricalItems: readonly ElectricalItem[]; + furnitureItems: readonly FurnitureItem[]; + annotations: readonly Annotation[]; + }>({ openings: [], electricalItems: [], furnitureItems: [], annotations: [] }); + + const stateRef = useRef(state); + stateRef.current = state; + + const copySelected = useCallback(() => { + const s = stateRef.current; + const ids = s.selectedIds; + if (ids.size === 0) return; + clipboardRef.current = { + openings: s.openings.filter((o) => ids.has(o.id)).map((o) => ({ ...o })), + electricalItems: s.electricalItems.filter((e) => ids.has(e.id)).map((e) => ({ ...e })), + furnitureItems: s.furnitureItems.filter((f) => ids.has(f.id)).map((f) => ({ ...f })), + annotations: s.annotations.filter((a) => ids.has(a.id)).map((a) => ({ ...a })), + }; + }, []); + + const pasteClipboard = useCallback(() => { + const cb = clipboardRef.current; + const hasItems = + cb.openings.length > 0 || + cb.electricalItems.length > 0 || + cb.furnitureItems.length > 0 || + cb.annotations.length > 0; + if (!hasItems) return; + + const PASTE_OFFSET = 0.2; + const newSelectedIds = new Set(); + + const newOpenings = cb.openings.map((o) => { + const newId = generateLocalId(); + newSelectedIds.add(newId); + return { ...o, id: newId, positionAlongWall: o.positionAlongWall + PASTE_OFFSET }; + }); + const newElectrical = cb.electricalItems.map((e) => { + const newId = generateLocalId(); + newSelectedIds.add(newId); + return { ...e, id: newId, x: e.x + PASTE_OFFSET, y: e.y + PASTE_OFFSET }; + }); + const newFurniture = cb.furnitureItems.map((f) => { + const newId = generateLocalId(); + newSelectedIds.add(newId); + return { ...f, id: newId, x: f.x + PASTE_OFFSET, y: f.y + PASTE_OFFSET }; + }); + const newAnnotations = cb.annotations.map((a) => { + const newId = generateLocalId(); + newSelectedIds.add(newId); + return { ...a, id: newId, x: a.x + PASTE_OFFSET, y: a.y + PASTE_OFFSET }; + }); + + const items: PastePayload = { + openings: newOpenings, + electricalItems: newElectrical, + furnitureItems: newFurniture, + annotations: newAnnotations, + newSelectedIds, + }; + dispatch({ type: 'PASTE_CLIPBOARD', items }); + }, []); + + // ── ZoomPan context value (changes only on zoom/panOffset) ── + const zoomPanValue = useMemo( + () => ({ zoom: state.zoom, panOffset: state.panOffset, setZoom, setPanOffset }), + [state.zoom, state.panOffset, setZoom, setPanOffset], + ); + + // ── Selection context value (changes only on selection/tool) ── + const selectionValue = useMemo( + () => ({ + selectedIds: state.selectedIds, + activeTool: state.activeTool, + dispatch, + setTool, + selectElement, + clearSelection, + deleteSelected, + selectAll, + }), + [state.selectedIds, state.activeTool, setTool, selectElement, clearSelection, deleteSelected, selectAll], + ); + + // ── SceneData context value (changes only on data edits) ── + const sceneDataValue = useMemo( + () => ({ + room: state.room, + walls: state.walls, + openings: state.openings, + electricalItems: state.electricalItems, + furnitureItems: state.furnitureItems, + annotations: state.annotations, + gridSize: state.gridSize, + gridVisible: state.gridVisible, + snapEnabled: state.snapEnabled, + snapGranularity: state.snapGranularity, + layerVisibility: state.layerVisibility, + selectedElectricalIndex: state.selectedElectricalIndex, + selectedFurnitureIndex: state.selectedFurnitureIndex, + dispatch, + setWalls, + updateWall, + addOpening, + updateOpening, + removeOpening, + addElectrical, + updateElectrical, + removeElectrical, + addFurniture, + updateFurniture, + removeFurniture, + addAnnotation, + updateAnnotation, + removeAnnotation, + copySelected, + pasteClipboard, + }), + [ + state.room, + state.walls, + state.openings, + state.electricalItems, + state.furnitureItems, + state.annotations, + state.gridSize, + state.gridVisible, + state.snapEnabled, + state.snapGranularity, + state.layerVisibility, + state.selectedElectricalIndex, + state.selectedFurnitureIndex, + setWalls, + updateWall, + addOpening, + updateOpening, + removeOpening, + addElectrical, + updateElectrical, + removeElectrical, + addFurniture, + updateFurniture, + removeFurniture, + addAnnotation, + updateAnnotation, + removeAnnotation, + copySelected, + pasteClipboard, + ], + ); + + // ── Legacy combined value ── + const legacyValue = useMemo( + () => ({ + state, + dispatch, + setTool, + setZoom, + setPanOffset, + selectElement, + clearSelection, + deleteSelected, + selectAll, + addOpening, + updateOpening, + removeOpening, + setWalls, + updateWall, + addElectrical, + updateElectrical, + removeElectrical, + addFurniture, + updateFurniture, + removeFurniture, + addAnnotation, + updateAnnotation, + removeAnnotation, + copySelected, + pasteClipboard, + }), + [ + state, + setTool, + setZoom, + setPanOffset, + selectElement, + clearSelection, + deleteSelected, + selectAll, + addOpening, + updateOpening, + removeOpening, + setWalls, + updateWall, + addElectrical, + updateElectrical, + removeElectrical, + addFurniture, + updateFurniture, + removeFurniture, + addAnnotation, + updateAnnotation, + removeAnnotation, + copySelected, + pasteClipboard, + ], + ); + + return ( + + + + + {children} + + + + + ); +} + +// ── Hooks ── + +/** Legacy hook -- combines all contexts. Use granular hooks for optimal re-render perf. */ +export function useEditor(): EditorContextValue { + const ctx = useContext(EditorContext); + if (!ctx) { + throw new Error('useEditor must be used within an EditorProvider'); + } + return ctx; +} + +/** Granular hook for zoom/pan state (60fps changes). */ +export function useZoomPan(): ZoomPanContextValue { + const ctx = useContext(ZoomPanContext); + if (!ctx) { + throw new Error('useZoomPan must be used within an EditorProvider'); + } + return ctx; +} + +/** Granular hook for selection/tool state. */ +export function useSelection(): SelectionContextValue { + const ctx = useContext(SelectionContext); + if (!ctx) { + throw new Error('useSelection must be used within an EditorProvider'); + } + return ctx; +} + +/** Granular hook for scene data (walls, openings, items, grid, layers). */ +export function useSceneData(): SceneDataContextValue { + const ctx = useContext(SceneDataContext); + if (!ctx) { + throw new Error('useSceneData must be used within an EditorProvider'); + } + return ctx; +} diff --git a/apps/client/src/components/editor/context/UndoRedoContext.tsx b/apps/client/src/components/editor/context/UndoRedoContext.tsx new file mode 100644 index 0000000..f78c0f4 --- /dev/null +++ b/apps/client/src/components/editor/context/UndoRedoContext.tsx @@ -0,0 +1,106 @@ +import { + createContext, + useContext, + useCallback, + useRef, + useMemo, + useState, + type ReactNode, +} from 'react'; +import type { EditorCommand } from '../types'; + +interface UndoRedoContextValue { + /** Execute a command and push it onto the undo stack. */ + execute(command: EditorCommand): void; + /** Undo the last command. */ + undo(): void; + /** Redo the last undone command. */ + redo(): void; + /** Whether there are commands to undo. */ + readonly canUndo: boolean; + /** Whether there are commands to redo. */ + readonly canRedo: boolean; +} + +const UndoRedoContext = createContext(null); + +const MAX_UNDO_STACK = 100; + +interface UndoRedoProviderProps { + readonly children: ReactNode; + /** Called whenever the stack changes so the parent can re-render. */ + readonly onStackChange?: () => void; +} + +export function UndoRedoProvider({ children, onStackChange }: UndoRedoProviderProps) { + const undoStackRef = useRef([]); + const redoStackRef = useRef([]); + // State counter to trigger re-renders when stack changes + const [version, setVersion] = useState(0); + + const notifyChange = useCallback(() => { + setVersion((v) => v + 1); + onStackChange?.(); + }, [onStackChange]); + + const execute = useCallback( + (command: EditorCommand) => { + command.execute(); + undoStackRef.current = [...undoStackRef.current, command].slice( + -MAX_UNDO_STACK, + ); + redoStackRef.current = []; + notifyChange(); + }, + [notifyChange], + ); + + const undo = useCallback(() => { + const stack = undoStackRef.current; + if (stack.length === 0) return; + + const command = stack[stack.length - 1]; + command.undo(); + undoStackRef.current = stack.slice(0, -1); + redoStackRef.current = [...redoStackRef.current, command]; + notifyChange(); + }, [notifyChange]); + + const redo = useCallback(() => { + const stack = redoStackRef.current; + if (stack.length === 0) return; + + const command = stack[stack.length - 1]; + command.execute(); + redoStackRef.current = stack.slice(0, -1); + undoStackRef.current = [...undoStackRef.current, command]; + notifyChange(); + }, [notifyChange]); + + const canUndo = undoStackRef.current.length > 0; + const canRedo = redoStackRef.current.length > 0; + + const value = useMemo( + () => ({ + execute, + undo, + redo, + canUndo, + canRedo, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [execute, undo, redo, version], + ); + + return ( + {children} + ); +} + +export function useUndoRedo(): UndoRedoContextValue { + const ctx = useContext(UndoRedoContext); + if (!ctx) { + throw new Error('useUndoRedo must be used within an UndoRedoProvider'); + } + return ctx; +} diff --git a/apps/client/src/components/editor/editor-toolbar.module.css b/apps/client/src/components/editor/editor-toolbar.module.css new file mode 100644 index 0000000..592f2e0 --- /dev/null +++ b/apps/client/src/components/editor/editor-toolbar.module.css @@ -0,0 +1,167 @@ +.toolbar { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + background-color: var(--color-bg-elevated); + border-bottom: 1px solid var(--color-border); + min-height: 44px; + flex-shrink: 0; +} + +.group { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.separator { + width: 1px; + height: 24px; + background-color: var(--color-border); + margin: 0 var(--space-1); +} + +.spacer { + flex: 1; +} + +.toolBtn { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border: 1px solid transparent; + border-radius: var(--radius-md); + background: none; + cursor: pointer; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + transition: background var(--transition-fast), color var(--transition-fast); + font-family: var(--font-family); +} + +.toolBtn:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.toolBtnActive { + background-color: var(--color-accent-50); + border-color: var(--color-accent-200); + color: var(--color-accent-700); +} + +.toolBtnActive:hover { + background-color: var(--color-accent-100); +} + +.toolIcon { + font-size: var(--font-size-base); + line-height: 1; +} + +.toolLabel { + font-size: var(--font-size-xs); +} + +.actionBtn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-md); + background: none; + cursor: pointer; + font-size: var(--font-size-md); + color: var(--color-text-secondary); + transition: background var(--transition-fast), color var(--transition-fast); + font-family: var(--font-family); +} + +.actionBtn:hover:not(:disabled) { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.actionBtn:disabled { + opacity: 0.35; + cursor: default; +} + +.toggleBtn { + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: none; + cursor: pointer; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + transition: all var(--transition-fast); + font-family: var(--font-family); +} + +.toggleBtn:hover { + background-color: var(--color-bg-hover); +} + +.toggleBtnActive { + background-color: var(--color-accent-50); + border-color: var(--color-accent-200); + color: var(--color-accent-700); +} + +.zoomLabel { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + min-width: 40px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.saveBtn { + padding: var(--space-1) var(--space-3); + border: none; + border-radius: var(--radius-md); + background-color: var(--color-accent-600); + color: var(--color-text-on-accent); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background var(--transition-fast); + font-family: var(--font-family); +} + +.saveBtn:hover:not(:disabled) { + background-color: var(--color-accent-700); +} + +.saveBtn:disabled { + opacity: 0.6; + cursor: default; +} + +.unsavedLabel { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-warning-600, #ca8a04); + padding: var(--space-1) var(--space-2); +} + +.autoSaveLabel { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + padding: var(--space-1) var(--space-2); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} diff --git a/apps/client/src/components/editor/export/ExportDialog.tsx b/apps/client/src/components/editor/export/ExportDialog.tsx new file mode 100644 index 0000000..cd23a52 --- /dev/null +++ b/apps/client/src/components/editor/export/ExportDialog.tsx @@ -0,0 +1,313 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '../../ui/Modal'; +import { useEditor } from '../context/EditorContext'; +import { + exportKonvaStageToDataUrl, + exportThreeCanvasToDataUrl, + downloadDataUrl, + downloadBlob, + createRoomPdf, + sanitizeFilename, +} from './exportUtils'; +import { exportRoomToJson } from './roomFormat'; +import { wallDirectionLabel } from '../utils/projectionMapping'; +import type Konva from 'konva'; +import styles from './export-dialog.module.css'; + +export type ExportFormat = 'png' | 'pdf' | 'json'; +export type ExportScope = 'current-view' | 'room'; + +interface ExportDialogProps { + readonly open: boolean; + readonly onClose: () => void; + readonly mainStageRef: React.RefObject; + readonly projectionStageRefs: React.RefObject>; + readonly threeCanvasRef: React.RefObject; + readonly is3DView: boolean; + readonly viewMode?: '2d' | '3d' | 'projections'; +} + +export function ExportDialog({ + open, + onClose, + mainStageRef, + projectionStageRefs, + threeCanvasRef, + is3DView, + viewMode = '2d', +}: ExportDialogProps) { + const { t } = useTranslation(); + const { state } = useEditor(); + const { room, walls } = state; + + const [format, setFormat] = useState('png'); + const [scope, setScope] = useState('current-view'); + const [includeGrid, setIncludeGrid] = useState(false); + const [pixelRatio, setPixelRatio] = useState(2); + const [isExporting, setIsExporting] = useState(false); + const [error, setError] = useState(null); + + const handleExport = useCallback(async () => { + setIsExporting(true); + setError(null); + + try { + const baseName = sanitizeFilename(room.name || 'room'); + + if (format === 'png') { + if (scope === 'current-view') { + // Export the currently visible view + if (viewMode === '3d') { + let canvas = threeCanvasRef.current; + if (!canvas) { + canvas = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null; + } + if (!canvas) { + setError(t('export.error.3dNotAvailable')); + return; + } + const dataUrl = exportThreeCanvasToDataUrl(canvas); + downloadDataUrl(dataUrl, `${baseName}_3d.png`); + } else if (viewMode === 'projections') { + const projMap = projectionStageRefs.current; + if (projMap && projMap.size > 0) { + for (const wall of walls) { + const projStage = projMap.get(wall.id); + if (projStage && projStage.width() > 0) { + const label = wallDirectionLabel(wall); + const safeName = sanitizeFilename(label); + const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio }); + downloadDataUrl(dataUrl, `${baseName}_wall_${safeName}.png`); + } + } + } else { + setError(t('export.error.2dNotAvailable')); + } + } else { + const stage = mainStageRef.current; + if (!stage || stage.width() === 0) { + setError(t('export.error.2dNotAvailable')); + return; + } + const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid }); + downloadDataUrl(dataUrl, `${baseName}_2d.png`); + } + } else { + // Export all views for the room (2D + projections) + const stage = mainStageRef.current; + if (stage) { + const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid }); + downloadDataUrl(dataUrl, `${baseName}_2d.png`); + } + + // Export projection views + const projMap = projectionStageRefs.current; + if (projMap) { + for (const wall of walls) { + const projStage = projMap.get(wall.id); + if (projStage) { + const label = wallDirectionLabel(wall); + const safeName = sanitizeFilename(label); + const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio }); + downloadDataUrl(dataUrl, `${baseName}_wall_${safeName}.png`); + } + } + } + } + } else if (format === 'json') { + // JSON export + const jsonStr = exportRoomToJson(state); + const blob = new Blob([jsonStr], { type: 'application/json' }); + downloadBlob(blob, `${baseName}.json`); + } else { + // PDF export + // Capture 2D view — temporarily restore size if hidden + let topDownDataUrl: string | null = null; + const stage = mainStageRef.current; + if (stage) { + const wasHidden = stage.width() === 0; + if (wasHidden) { + // Temporarily make the stage visible for capture + const container = stage.container(); + const parent = container?.parentElement; + const origStyle = parent?.getAttribute('style') ?? ''; + if (parent) { + parent.setAttribute('style', 'position:absolute;width:1200px;height:900px;overflow:hidden;pointer-events:none;opacity:0;'); + } + stage.width(1200); + stage.height(900); + stage.batchDraw(); + } + if (stage.width() > 0 && stage.height() > 0) { + topDownDataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid }); + } + if (wasHidden) { + // Restore hidden state + const container = stage.container(); + const parent = container?.parentElement; + if (parent) { + parent.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;'); + } + stage.width(0); + stage.height(0); + } + } + + // Capture projection views — they may not be mounted if not in projection mode + const projectionDataUrls: { label: string; dataUrl: string }[] = []; + const projMap = projectionStageRefs.current; + if (projMap) { + for (const wall of walls) { + const projStage = projMap.get(wall.id); + if (projStage && projStage.width() > 0 && projStage.height() > 0) { + const label = wallDirectionLabel(wall); + const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio }); + projectionDataUrls.push({ label, dataUrl }); + } + } + } + + let view3dDataUrl: string | null = null; + // Try the ref first, then fall back to querying the DOM + let canvas3d = threeCanvasRef.current; + if (!canvas3d) { + canvas3d = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null; + } + if (canvas3d && canvas3d.width > 0 && canvas3d.height > 0) { + view3dDataUrl = exportThreeCanvasToDataUrl(canvas3d); + } + + const pdf = await createRoomPdf(room.name, topDownDataUrl, projectionDataUrls, view3dDataUrl); + const blob = pdf.output('blob'); + downloadBlob(blob, `${baseName}.pdf`); + } + + onClose(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('export.error.failed'); + setError(message); + } finally { + setIsExporting(false); + } + }, [format, scope, includeGrid, pixelRatio, is3DView, state, room, walls, mainStageRef, projectionStageRefs, threeCanvasRef, onClose, t]); + + const footer = ( +
+ + +
+ ); + + return ( + +
+ {/* Format */} +
+ {t('export.format')} +
+ + + +
+
+ + {/* Scope (only for PNG) */} + {format === 'png' && ( +
+ {t('export.scope')} +
+ + +
+
+ )} + + {/* Options */} +
+ {t('export.options')} + {!is3DView && ( + + )} + +
+ + {error &&
{error}
} + {isExporting &&
{t('export.generating')}
} +
+
+ ); +} diff --git a/apps/client/src/components/editor/export/__tests__/exportUtils.test.ts b/apps/client/src/components/editor/export/__tests__/exportUtils.test.ts new file mode 100644 index 0000000..270d0c8 --- /dev/null +++ b/apps/client/src/components/editor/export/__tests__/exportUtils.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeFilename } from '../exportUtils'; + +describe('sanitizeFilename', () => { + it('removes special characters', () => { + expect(sanitizeFilename('Room #1 (Main)')).toBe('Room_1_Main'); + }); + + it('replaces spaces with underscores', () => { + expect(sanitizeFilename('My Room')).toBe('My_Room'); + }); + + it('collapses multiple spaces', () => { + expect(sanitizeFilename('Room Name')).toBe('Room_Name'); + }); + + it('preserves hyphens and underscores', () => { + expect(sanitizeFilename('room-1_main')).toBe('room-1_main'); + }); + + it('handles empty string', () => { + expect(sanitizeFilename('')).toBe(''); + }); + + it('handles string with only special characters', () => { + expect(sanitizeFilename('!@#$%')).toBe(''); + }); +}); diff --git a/apps/client/src/components/editor/export/export-dialog.module.css b/apps/client/src/components/editor/export/export-dialog.module.css new file mode 100644 index 0000000..82e9932 --- /dev/null +++ b/apps/client/src/components/editor/export/export-dialog.module.css @@ -0,0 +1,121 @@ +.content { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 380px; +} + +.fieldGroup { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.fieldLabel { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); +} + +.radioGroup { + display: flex; + gap: var(--space-3); +} + +.radioLabel { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + cursor: pointer; +} + +.radioLabel input[type="radio"] { + accent-color: var(--color-accent-600); +} + +.checkboxLabel { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + cursor: pointer; +} + +.checkboxLabel input[type="checkbox"] { + accent-color: var(--color-accent-600); +} + +.scaleInput { + width: 60px; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-family: var(--font-family); + background: var(--color-bg); + color: var(--color-text-primary); +} + +.scaleInput:focus { + outline: 2px solid var(--color-accent-400); + outline-offset: -1px; +} + +.footerButtons { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} + +.cancelBtn { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: none; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + font-family: var(--font-family); +} + +.cancelBtn:hover { + background-color: var(--color-bg-hover); +} + +.exportBtn { + padding: var(--space-2) var(--space-4); + border: none; + border-radius: var(--radius-md); + background-color: var(--color-accent-600); + color: var(--color-text-on-accent); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + font-family: var(--font-family); + transition: background var(--transition-fast); +} + +.exportBtn:hover:not(:disabled) { + background-color: var(--color-accent-700); +} + +.exportBtn:disabled { + opacity: 0.6; + cursor: default; +} + +.statusMessage { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + padding: var(--space-1) 0; +} + +.errorMessage { + font-size: var(--font-size-xs); + color: var(--color-danger-600); + padding: var(--space-1) 0; +} diff --git a/apps/client/src/components/editor/export/exportUtils.ts b/apps/client/src/components/editor/export/exportUtils.ts new file mode 100644 index 0000000..05a1ff0 --- /dev/null +++ b/apps/client/src/components/editor/export/exportUtils.ts @@ -0,0 +1,246 @@ +import type Konva from 'konva'; +import type { jsPDF as JsPDFType } from 'jspdf'; + +// ── PNG Export Options ── + +export interface PngExportOptions { + readonly pixelRatio?: number; + readonly mimeType?: 'image/png' | 'image/jpeg'; + readonly quality?: number; +} + +// ── Stage-based PNG export ── + +/** + * Export a Konva stage to a data URL. + * Optionally hides the grid layer before exporting. + */ +export function exportKonvaStageToDataUrl( + stage: Konva.Stage, + options: PngExportOptions & { readonly includeGrid?: boolean } = {}, +): string { + const { pixelRatio = 2, mimeType = 'image/png', quality = 1, includeGrid = false } = options; + + // Temporarily hide grid layer if requested (only for multi-layer stages like the 2D editor) + const layers = stage.getLayers(); + // Grid is the first layer only when there are multiple layers (2D editor). + // Single-layer stages (projections) should never have their layer hidden. + const gridLayer = layers.length > 1 ? layers[0] : null; + const gridWasVisible = gridLayer?.visible() ?? false; + + if (!includeGrid && gridLayer) { + gridLayer.visible(false); + stage.draw(); + } + + // Force a synchronous redraw before capture + stage.draw(); + + const dataUrl = stage.toDataURL({ + pixelRatio, + mimeType, + quality, + x: 0, + y: 0, + width: stage.width(), + height: stage.height(), + }); + + // Restore grid visibility + if (!includeGrid && gridLayer && gridWasVisible) { + gridLayer.visible(true); + stage.draw(); + } + + return dataUrl; +} + +/** + * Export a Three.js canvas element to a data URL. + */ +export function exportThreeCanvasToDataUrl( + canvas: HTMLCanvasElement, +): string { + return canvas.toDataURL('image/png'); +} + +// ── Download helpers ── + +/** + * Trigger browser download of a data URL as a file. + */ +export function downloadDataUrl(dataUrl: string, filename: string): void { + const link = document.createElement('a'); + link.download = filename; + link.href = dataUrl; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +/** + * Trigger browser download of a Blob. + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + downloadDataUrl(url, filename); + URL.revokeObjectURL(url); +} + +// ── PDF Export ── + +export interface PdfExportOptions { + readonly title: string; + readonly includeGrid?: boolean; + readonly pixelRatio?: number; +} + +/** + * Lazy-load jsPDF for code splitting. + */ +async function loadJsPDF(): Promise { + return import('jspdf'); +} + +/** + * Create a single-room PDF with: + * - Room name header + * - 2D top-down view + * - Up to 4 wall projection views + */ +export async function createRoomPdf( + roomName: string, + topDownDataUrl: string | null, + projectionDataUrls: readonly { readonly label: string; readonly dataUrl: string }[], + view3dDataUrl: string | null, +): Promise { + const { jsPDF } = await loadJsPDF(); + const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + + // ── Page 1: Room name + 2D view ── + pdf.setFontSize(18); + pdf.text(roomName, pageWidth / 2, 15, { align: 'center' }); + + if (topDownDataUrl) { + const imgWidth = pageWidth - 30; + const imgHeight = (pageHeight - 40) * 0.55; + pdf.addImage(topDownDataUrl, 'PNG', 15, 25, imgWidth, imgHeight); + } + + // ── Page 2: Wall projections (if any) ── + if (projectionDataUrls.length > 0) { + pdf.addPage('a4', 'landscape'); + pdf.setFontSize(14); + pdf.text(`${roomName} - Wall Projections`, pageWidth / 2, 15, { align: 'center' }); + + const cols = 2; + const rows = 2; + const cellW = (pageWidth - 30) / cols; + const cellH = (pageHeight - 35) / rows; + + for (let i = 0; i < Math.min(projectionDataUrls.length, 4); i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const x = 15 + col * cellW; + const y = 22 + row * cellH; + + const proj = projectionDataUrls[i]; + pdf.setFontSize(9); + pdf.text(proj.label, x + cellW / 2, y + 2, { align: 'center' }); + pdf.addImage(proj.dataUrl, 'PNG', x + 2, y + 5, cellW - 4, cellH - 10); + } + } + + // ── Page 3: 3D view (if available) ── + if (view3dDataUrl) { + pdf.addPage('a4', 'landscape'); + pdf.setFontSize(14); + pdf.text(`${roomName} - 3D View`, pageWidth / 2, 15, { align: 'center' }); + + const imgWidth = pageWidth - 30; + const imgHeight = pageHeight - 30; + pdf.addImage(view3dDataUrl, 'PNG', 15, 20, imgWidth, imgHeight); + } + + return pdf; +} + +/** + * Create a multi-room apartment PDF with a cover page. + */ +export async function createApartmentPdf( + apartmentName: string, + rooms: readonly { + readonly roomName: string; + readonly topDownDataUrl: string | null; + readonly projectionDataUrls: readonly { readonly label: string; readonly dataUrl: string }[]; + }[], +): Promise { + const { jsPDF } = await loadJsPDF(); + const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + + // ── Cover page ── + pdf.setFontSize(28); + pdf.text(apartmentName, pageWidth / 2, pageHeight / 3, { align: 'center' }); + pdf.setFontSize(14); + pdf.text('House Plan', pageWidth / 2, pageHeight / 3 + 12, { align: 'center' }); + pdf.setFontSize(10); + pdf.text(`Generated: ${new Date().toLocaleDateString()}`, pageWidth / 2, pageHeight / 3 + 22, { align: 'center' }); + pdf.setFontSize(11); + pdf.text( + `${rooms.length} room${rooms.length === 1 ? '' : 's'}`, + pageWidth / 2, + pageHeight / 3 + 30, + { align: 'center' }, + ); + + // ── Room pages ── + for (const room of rooms) { + pdf.addPage('a4', 'landscape'); + pdf.setFontSize(18); + pdf.text(room.roomName, pageWidth / 2, 15, { align: 'center' }); + + if (room.topDownDataUrl) { + const imgWidth = pageWidth - 30; + const imgHeight = (pageHeight - 40) * 0.55; + pdf.addImage(room.topDownDataUrl, 'PNG', 15, 25, imgWidth, imgHeight); + } + + // Projections page + if (room.projectionDataUrls.length > 0) { + pdf.addPage('a4', 'landscape'); + pdf.setFontSize(14); + pdf.text(`${room.roomName} - Wall Projections`, pageWidth / 2, 15, { align: 'center' }); + + const cols = 2; + const rows = 2; + const cellW = (pageWidth - 30) / cols; + const cellH = (pageHeight - 35) / rows; + + for (let i = 0; i < Math.min(room.projectionDataUrls.length, 4); i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const x = 15 + col * cellW; + const y = 22 + row * cellH; + + const proj = room.projectionDataUrls[i]; + pdf.setFontSize(9); + pdf.text(proj.label, x + cellW / 2, y + 2, { align: 'center' }); + pdf.addImage(proj.dataUrl, 'PNG', x + 2, y + 5, cellW - 4, cellH - 10); + } + } + } + + return pdf; +} + +/** + * Sanitize a filename for download. + */ +export function sanitizeFilename(name: string): string { + return name.replace(/[^a-zA-Z0-9_\-\s]/g, '').replace(/\s+/g, '_'); +} diff --git a/apps/client/src/components/editor/export/roomFormat.ts b/apps/client/src/components/editor/export/roomFormat.ts new file mode 100644 index 0000000..806af32 --- /dev/null +++ b/apps/client/src/components/editor/export/roomFormat.ts @@ -0,0 +1,405 @@ +import type { + Point, + Wall, + WallOpening, + ElectricalItem, + FurnitureItem, + WallDirection, + OpeningType, + ElectricalType, + FurnitureType, + RoomFull, +} from '@house-plan-maker/shared'; +import { + WALL_DIRECTIONS, + OPENING_TYPES, + ELECTRICAL_TYPES, + FURNITURE_TYPES, +} from '@house-plan-maker/shared'; +import type { EditorState } from '../types'; +import { generateLocalId } from '../utils/geometry'; + +// ── Export Format ── + +interface RoomExportData { + readonly version: 1; + readonly room: { + readonly name: string; + readonly shape: readonly Point[]; + readonly wallHeight: number; + readonly plinthHeight: number; + readonly plinthThickness: number; + }; + readonly walls: readonly { + readonly startX: number; + readonly startY: number; + readonly endX: number; + readonly endY: number; + readonly thickness: number; + readonly direction: string; + }[]; + readonly openings: readonly { + readonly wallIndex: number; + readonly type: string; + readonly positionAlongWall: number; + readonly width: number; + readonly height: number; + readonly elevationFromFloor: number; + }[]; + readonly electricalItems: readonly { + readonly type: string; + readonly x: number; + readonly y: number; + readonly wallIndex: number | null; + readonly elevationFromFloor: number | null; + readonly rotation: number; + readonly metadata: Record | null; + }[]; + readonly furnitureItems: readonly { + readonly type: string; + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly height: number; + readonly rotation: number; + readonly label: string | null; + }[]; +} + +// ── Import Result ── + +interface ImportResult { + readonly room: { + readonly name: string; + readonly shape: readonly Point[]; + readonly wallHeight: number; + readonly plinthHeight: number; + readonly plinthThickness: number; + }; + readonly walls: readonly Wall[]; + readonly openings: readonly WallOpening[]; + readonly electricalItems: readonly ElectricalItem[]; + readonly furnitureItems: readonly FurnitureItem[]; +} + +// ── Export ── + +export function exportRoomToJson(state: EditorState): string { + const { room, walls, openings, electricalItems, furnitureItems } = state; + + // Build wall ID -> index map + const wallIdToIndex = new Map(); + for (let i = 0; i < walls.length; i++) { + wallIdToIndex.set(walls[i].id, i); + } + + const exportData: RoomExportData = { + version: 1, + room: { + name: room.name, + shape: room.shape, + wallHeight: room.wallHeight, + plinthHeight: room.plinthHeight, + plinthThickness: room.plinthThickness, + }, + walls: walls.map((w) => ({ + startX: w.startX, + startY: w.startY, + endX: w.endX, + endY: w.endY, + thickness: w.thickness, + direction: w.direction, + })), + openings: openings.map((o) => ({ + wallIndex: wallIdToIndex.get(o.wallId) ?? -1, + type: o.type, + positionAlongWall: o.positionAlongWall, + width: o.width, + height: o.height, + elevationFromFloor: o.elevationFromFloor, + })), + electricalItems: electricalItems.map((e) => ({ + type: e.type, + x: e.x, + y: e.y, + wallIndex: e.wallId !== null ? (wallIdToIndex.get(e.wallId) ?? null) : null, + elevationFromFloor: e.elevationFromFloor, + rotation: e.rotation, + metadata: e.metadata, + })), + furnitureItems: furnitureItems.map((f) => ({ + type: f.type, + x: f.x, + y: f.y, + width: f.width, + depth: f.depth, + height: f.height, + rotation: f.rotation, + label: f.label, + })), + }; + + return JSON.stringify(exportData, null, 2); +} + +// ── Validation Helpers ── + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +function isPoint(value: unknown): value is Point { + return isRecord(value) && isNumber(value.x) && isNumber(value.y); +} + +function assertField( + obj: Record, + field: string, + check: (v: unknown) => v is T, + label: string, +): T { + const value = obj[field]; + if (!check(value)) { + throw new Error(`Invalid or missing field "${field}" in ${label}`); + } + return value; +} + +function validateWallDirection(value: string): WallDirection { + if (!(WALL_DIRECTIONS as readonly string[]).includes(value)) { + throw new Error(`Invalid wall direction: "${value}". Expected one of: ${WALL_DIRECTIONS.join(', ')}`); + } + return value as WallDirection; +} + +function validateOpeningType(value: string): OpeningType { + if (!(OPENING_TYPES as readonly string[]).includes(value)) { + throw new Error(`Invalid opening type: "${value}". Expected one of: ${OPENING_TYPES.join(', ')}`); + } + return value as OpeningType; +} + +function validateElectricalType(value: string): ElectricalType { + if (!(ELECTRICAL_TYPES as readonly string[]).includes(value)) { + throw new Error(`Invalid electrical type: "${value}". Expected one of: ${ELECTRICAL_TYPES.join(', ')}`); + } + return value as ElectricalType; +} + +function validateFurnitureType(value: string): FurnitureType { + if (!(FURNITURE_TYPES as readonly string[]).includes(value)) { + throw new Error(`Invalid furniture type: "${value}". Expected one of: ${FURNITURE_TYPES.join(', ')}`); + } + return value as FurnitureType; +} + +// ── Import ── + +export function importRoomFromJson(json: string): ImportResult { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new Error('Invalid JSON: the file does not contain valid JSON data.'); + } + + if (!isRecord(parsed)) { + throw new Error('Invalid format: expected a JSON object at the top level.'); + } + + // Version check + if (parsed.version !== 1) { + throw new Error( + `Unsupported format version: ${String(parsed.version)}. Expected version 1.`, + ); + } + + // ── Room ── + if (!isRecord(parsed.room)) { + throw new Error('Missing or invalid "room" field.'); + } + const roomData = parsed.room; + const roomName = assertField(roomData, 'name', isString, 'room'); + const roomWallHeight = assertField(roomData, 'wallHeight', isNumber, 'room'); + const roomPlinthHeight = assertField(roomData, 'plinthHeight', isNumber, 'room'); + const roomPlinthThickness = assertField(roomData, 'plinthThickness', isNumber, 'room'); + + if (!Array.isArray(roomData.shape)) { + throw new Error('Missing or invalid "room.shape" field: expected an array of points.'); + } + const shape: Point[] = []; + for (let i = 0; i < roomData.shape.length; i++) { + const pt = roomData.shape[i]; + if (!isPoint(pt)) { + throw new Error(`Invalid point at room.shape[${i}]: expected { x: number, y: number }.`); + } + shape.push({ x: pt.x, y: pt.y }); + } + + // ── Walls ── + if (!Array.isArray(parsed.walls)) { + throw new Error('Missing or invalid "walls" field: expected an array.'); + } + + const roomId = generateLocalId(); + const wallIds: string[] = []; + const walls: Wall[] = []; + + for (let i = 0; i < parsed.walls.length; i++) { + const w = parsed.walls[i]; + if (!isRecord(w)) { + throw new Error(`Invalid wall at index ${i}: expected an object.`); + } + const wallId = generateLocalId(); + wallIds.push(wallId); + + walls.push({ + id: wallId, + roomId, + startX: assertField(w, 'startX', isNumber, `walls[${i}]`), + startY: assertField(w, 'startY', isNumber, `walls[${i}]`), + endX: assertField(w, 'endX', isNumber, `walls[${i}]`), + endY: assertField(w, 'endY', isNumber, `walls[${i}]`), + thickness: assertField(w, 'thickness', isNumber, `walls[${i}]`), + direction: validateWallDirection( + assertField(w, 'direction', isString, `walls[${i}]`), + ), + }); + } + + // ── Openings ── + if (!Array.isArray(parsed.openings)) { + throw new Error('Missing or invalid "openings" field: expected an array.'); + } + + const openings: WallOpening[] = []; + for (let i = 0; i < parsed.openings.length; i++) { + const o = parsed.openings[i]; + if (!isRecord(o)) { + throw new Error(`Invalid opening at index ${i}: expected an object.`); + } + const wallIndex = assertField(o, 'wallIndex', isNumber, `openings[${i}]`); + if (!Number.isInteger(wallIndex) || wallIndex < 0 || wallIndex >= walls.length) { + throw new Error( + `Invalid wallIndex ${wallIndex} in openings[${i}]: must be 0..${walls.length - 1}.`, + ); + } + + openings.push({ + id: generateLocalId(), + roomId, + wallId: wallIds[wallIndex], + type: validateOpeningType( + assertField(o, 'type', isString, `openings[${i}]`), + ), + positionAlongWall: assertField(o, 'positionAlongWall', isNumber, `openings[${i}]`), + width: assertField(o, 'width', isNumber, `openings[${i}]`), + height: assertField(o, 'height', isNumber, `openings[${i}]`), + elevationFromFloor: assertField(o, 'elevationFromFloor', isNumber, `openings[${i}]`), + openDirection: (isString((o as Record).openDirection) ? (o as Record).openDirection as string : 'LEFT') as WallOpening['openDirection'], + }); + } + + // ── Electrical Items ── + if (!Array.isArray(parsed.electricalItems)) { + throw new Error('Missing or invalid "electricalItems" field: expected an array.'); + } + + const electricalItems: ElectricalItem[] = []; + for (let i = 0; i < parsed.electricalItems.length; i++) { + const e = parsed.electricalItems[i]; + if (!isRecord(e)) { + throw new Error(`Invalid electrical item at index ${i}: expected an object.`); + } + const wallIndex = e.wallIndex; + let wallId: string | null = null; + if (wallIndex !== null) { + if (!isNumber(wallIndex) || !Number.isInteger(wallIndex) || wallIndex < 0 || wallIndex >= walls.length) { + throw new Error( + `Invalid wallIndex ${String(wallIndex)} in electricalItems[${i}]: must be null or 0..${walls.length - 1}.`, + ); + } + wallId = wallIds[wallIndex]; + } + + const metadata = e.metadata; + let validatedMetadata: Record | null = null; + if (metadata !== null && metadata !== undefined) { + if (!isRecord(metadata)) { + throw new Error(`Invalid metadata in electricalItems[${i}]: expected an object or null.`); + } + validatedMetadata = metadata; + } + + electricalItems.push({ + id: generateLocalId(), + roomId, + type: validateElectricalType( + assertField(e, 'type', isString, `electricalItems[${i}]`), + ), + x: assertField(e, 'x', isNumber, `electricalItems[${i}]`), + y: assertField(e, 'y', isNumber, `electricalItems[${i}]`), + wallId, + elevationFromFloor: e.elevationFromFloor === null ? null : assertField(e, 'elevationFromFloor', isNumber, `electricalItems[${i}]`), + rotation: assertField(e, 'rotation', isNumber, `electricalItems[${i}]`), + metadata: validatedMetadata, + }); + } + + // ── Furniture Items ── + if (!Array.isArray(parsed.furnitureItems)) { + throw new Error('Missing or invalid "furnitureItems" field: expected an array.'); + } + + const furnitureItems: FurnitureItem[] = []; + for (let i = 0; i < parsed.furnitureItems.length; i++) { + const f = parsed.furnitureItems[i]; + if (!isRecord(f)) { + throw new Error(`Invalid furniture item at index ${i}: expected an object.`); + } + + const label = f.label; + if (label !== null && !isString(label)) { + throw new Error(`Invalid label in furnitureItems[${i}]: expected a string or null.`); + } + + furnitureItems.push({ + id: generateLocalId(), + roomId, + type: validateFurnitureType( + assertField(f, 'type', isString, `furnitureItems[${i}]`), + ), + x: assertField(f, 'x', isNumber, `furnitureItems[${i}]`), + y: assertField(f, 'y', isNumber, `furnitureItems[${i}]`), + width: assertField(f, 'width', isNumber, `furnitureItems[${i}]`), + depth: assertField(f, 'depth', isNumber, `furnitureItems[${i}]`), + height: assertField(f, 'height', isNumber, `furnitureItems[${i}]`), + rotation: assertField(f, 'rotation', isNumber, `furnitureItems[${i}]`), + elevationFromFloor: isNumber((f as Record).elevationFromFloor) ? (f as Record).elevationFromFloor as number : 0, + label: (label as string | null) ?? null, + }); + } + + return { + room: { + name: roomName, + shape, + wallHeight: roomWallHeight, + plinthHeight: roomPlinthHeight, + plinthThickness: roomPlinthThickness, + }, + walls, + openings, + electricalItems, + furnitureItems, + }; +} diff --git a/apps/client/src/components/editor/hooks/useEditorZoom.ts b/apps/client/src/components/editor/hooks/useEditorZoom.ts new file mode 100644 index 0000000..1ee131f --- /dev/null +++ b/apps/client/src/components/editor/hooks/useEditorZoom.ts @@ -0,0 +1,170 @@ +import { useCallback, useRef } from 'react'; +import type { Point } from '@house-plan-maker/shared'; +import type Konva from 'konva'; +import { clamp } from '../utils/geometry'; +import { MIN_ZOOM, MAX_ZOOM, ZOOM_SENSITIVITY, DEFAULT_ZOOM } from '../types'; + +interface UseEditorZoomOptions { + readonly zoom: number; + readonly panOffset: Point; + readonly onZoomChange: (zoom: number) => void; + readonly onPanChange: (offset: Point) => void; +} + +interface UseEditorZoomResult { + /** Handle mouse wheel for zooming. Attach to Stage. */ + handleWheel(e: Konva.KonvaEventObject): void; + /** Convert screen pixel coordinates to world (meter) coordinates. */ + screenToWorld(screenX: number, screenY: number): Point; + /** Convert world (meter) coordinates to screen pixel coordinates. */ + worldToScreen(worldX: number, worldY: number): Point; + /** Zoom in by a fixed step. */ + zoomIn(): void; + /** Zoom out by a fixed step. */ + zoomOut(): void; + /** Reset zoom and pan to fit the stage. */ + resetView(): void; + /** Whether middle mouse is currently panning. */ + isPanningRef: React.RefObject; + /** Handle mouse down for panning (middle mouse button). */ + handlePanStart(e: Konva.KonvaEventObject): void; + /** Start panning from left mouse button (empty space click). */ + startLeftMousePan(e: Konva.KonvaEventObject): void; + /** Handle mouse move for panning. */ + handlePanMove(e: Konva.KonvaEventObject): void; + /** Handle mouse up to end panning. */ + handlePanEnd(): void; +} + +const ZOOM_STEP_FACTOR = 1.2; + +export function useEditorZoom({ + zoom, + panOffset, + onZoomChange, + onPanChange, +}: UseEditorZoomOptions): UseEditorZoomResult { + const isPanningRef = useRef(false); + const lastPanPointRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const panAccumRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + + const screenToWorld = useCallback( + (screenX: number, screenY: number): Point => ({ + x: (screenX - panOffset.x) / zoom, + y: (screenY - panOffset.y) / zoom, + }), + [zoom, panOffset], + ); + + const worldToScreen = useCallback( + (worldX: number, worldY: number): Point => ({ + x: worldX * zoom + panOffset.x, + y: worldY * zoom + panOffset.y, + }), + [zoom, panOffset], + ); + + const handleWheel = useCallback( + (e: Konva.KonvaEventObject) => { + e.evt.preventDefault(); + + const stage = e.target.getStage(); + if (!stage) return; + + const pointerPos = stage.getPointerPosition(); + if (!pointerPos) return; + + // Get world point under cursor before zoom + const worldBefore = screenToWorld(pointerPos.x, pointerPos.y); + + // Compute new zoom + const delta = -e.evt.deltaY * ZOOM_SENSITIVITY; + const scale = Math.exp(delta); + const newZoom = clamp(zoom * scale, MIN_ZOOM, MAX_ZOOM); + + // Adjust pan so the world point stays under the cursor + const newPanOffset: Point = { + x: pointerPos.x - worldBefore.x * newZoom, + y: pointerPos.y - worldBefore.y * newZoom, + }; + + onZoomChange(newZoom); + onPanChange(newPanOffset); + }, + [zoom, screenToWorld, onZoomChange, onPanChange], + ); + + const zoomIn = useCallback(() => { + const newZoom = clamp(zoom * ZOOM_STEP_FACTOR, MIN_ZOOM, MAX_ZOOM); + onZoomChange(newZoom); + }, [zoom, onZoomChange]); + + const zoomOut = useCallback(() => { + const newZoom = clamp(zoom / ZOOM_STEP_FACTOR, MIN_ZOOM, MAX_ZOOM); + onZoomChange(newZoom); + }, [zoom, onZoomChange]); + + const resetView = useCallback(() => { + onZoomChange(DEFAULT_ZOOM); + onPanChange({ x: 50, y: 50 }); + }, [onZoomChange, onPanChange]); + + const handlePanStart = useCallback( + (e: Konva.KonvaEventObject) => { + // Middle mouse button (button === 1) — always pans + if (e.evt.button === 1) { + e.evt.preventDefault(); + isPanningRef.current = true; + lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY }; + panAccumRef.current = { x: panOffset.x, y: panOffset.y }; + } + }, + [panOffset], + ); + + /** Start panning from left mouse button (called by canvas on empty-space click). */ + const startLeftMousePan = useCallback( + (e: Konva.KonvaEventObject) => { + isPanningRef.current = true; + lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY }; + panAccumRef.current = { x: panOffset.x, y: panOffset.y }; + }, + [panOffset], + ); + + const handlePanMove = useCallback( + (e: Konva.KonvaEventObject) => { + if (!isPanningRef.current) return; + + const dx = e.evt.clientX - lastPanPointRef.current.x; + const dy = e.evt.clientY - lastPanPointRef.current.y; + + lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY }; + panAccumRef.current = { + x: panAccumRef.current.x + dx, + y: panAccumRef.current.y + dy, + }; + + onPanChange(panAccumRef.current); + }, + [onPanChange], + ); + + const handlePanEnd = useCallback(() => { + isPanningRef.current = false; + }, []); + + return { + handleWheel, + screenToWorld, + worldToScreen, + zoomIn, + zoomOut, + resetView, + isPanningRef, + handlePanStart, + startLeftMousePan, + handlePanMove, + handlePanEnd, + }; +} diff --git a/apps/client/src/components/editor/hooks/useKeyboardShortcuts.ts b/apps/client/src/components/editor/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..837652d --- /dev/null +++ b/apps/client/src/components/editor/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,129 @@ +import { useEffect } from 'react'; +import { useEditor } from '../context/EditorContext'; +import { useUndoRedo } from '../context/UndoRedoContext'; + +interface UseKeyboardShortcutsOptions { + /** Called when Ctrl+S is pressed. */ + readonly onSave: () => void; +} + +export function useKeyboardShortcuts({ onSave }: UseKeyboardShortcutsOptions): void { + const { deleteSelected, clearSelection, selectAll, setTool, copySelected, pasteClipboard } = useEditor(); + const { undo, redo } = useUndoRedo(); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + const target = e.target as HTMLElement; + // Ignore shortcuts when typing in input fields + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable + ) { + return; + } + + const isCtrl = e.ctrlKey || e.metaKey; + + // Ctrl+Z / Ctrl+Shift+Z — undo/redo + if (isCtrl && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + undo(); + return; + } + if (isCtrl && e.key === 'z' && e.shiftKey) { + e.preventDefault(); + redo(); + return; + } + // Ctrl+Y — redo (alternative) + if (isCtrl && e.key === 'y') { + e.preventDefault(); + redo(); + return; + } + + // Ctrl+S — save + if (isCtrl && e.key === 's') { + e.preventDefault(); + onSave(); + return; + } + + // Ctrl+C — copy selected + if (isCtrl && e.key === 'c') { + e.preventDefault(); + copySelected(); + return; + } + + // Ctrl+V — paste clipboard + if (isCtrl && e.key === 'v') { + e.preventDefault(); + pasteClipboard(); + return; + } + + // Ctrl+D — duplicate (copy + paste) + if (isCtrl && e.key === 'd') { + e.preventDefault(); + copySelected(); + pasteClipboard(); + return; + } + + // Ctrl+A — select all + if (isCtrl && e.key === 'a') { + e.preventDefault(); + selectAll(); + return; + } + + // Delete / Backspace — delete selected + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + deleteSelected(); + return; + } + + // Escape — deselect / cancel tool + if (e.key === 'Escape') { + e.preventDefault(); + clearSelection(); + setTool('select'); + return; + } + + // Tool shortcuts (single keys, no modifiers) + if (!isCtrl && !e.altKey) { + switch (e.key.toLowerCase()) { + case 'v': + setTool('select'); + break; + case 'd': + setTool('door'); + break; + case 'w': + setTool('window'); + break; + case 'e': + setTool('electrical'); + break; + case 'f': + setTool('furniture'); + break; + case 'm': + setTool('measure'); + break; + case 't': + setTool('annotate'); + break; + } + } + } + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [undo, redo, onSave, selectAll, deleteSelected, clearSelection, setTool, copySelected, pasteClipboard]); +} diff --git a/apps/client/src/components/editor/hooks/useSnapping.ts b/apps/client/src/components/editor/hooks/useSnapping.ts new file mode 100644 index 0000000..226a25a --- /dev/null +++ b/apps/client/src/components/editor/hooks/useSnapping.ts @@ -0,0 +1,79 @@ +import { useCallback } from 'react'; +import type { Point, Wall } from '@house-plan-maker/shared'; +import { snapPointToGrid } from '../utils/geometry'; +import { findNearestWall } from '../utils/wallUtils'; + +interface UseSnappingOptions { + readonly snapEnabled: boolean; + readonly snapGranularity: number; + readonly walls: readonly Wall[]; +} + +/** Maximum distance (in meters) to snap to a wall. */ +const WALL_SNAP_DISTANCE = 0.3; + +interface UseSnappingResult { + /** Snap a point to the grid. */ + snapToGrid(point: Point): Point; + /** + * Snap a point to the nearest wall if close enough. + * Returns the snapped point and the wall it snapped to. + */ + snapToWall(point: Point): { snapped: Point; wall: Wall | null; positionAlongWall: number }; + /** + * Apply all active snapping (grid + wall). + * Wall snapping takes priority when close enough. + */ + snapPoint(point: Point): Point; +} + +export function useSnapping({ + snapEnabled, + snapGranularity, + walls, +}: UseSnappingOptions): UseSnappingResult { + const snapToGridFn = useCallback( + (point: Point): Point => { + if (!snapEnabled) return point; + return snapPointToGrid(point, snapGranularity); + }, + [snapEnabled, snapGranularity], + ); + + const snapToWallFn = useCallback( + ( + point: Point, + ): { snapped: Point; wall: Wall | null; positionAlongWall: number } => { + if (!snapEnabled || walls.length === 0) { + return { snapped: point, wall: null, positionAlongWall: 0 }; + } + + const nearest = findNearestWall(point, walls); + if (!nearest || nearest.distance > WALL_SNAP_DISTANCE) { + return { snapped: point, wall: null, positionAlongWall: 0 }; + } + + return { + snapped: nearest.projected, + wall: nearest.wall, + positionAlongWall: nearest.positionAlongWall, + }; + }, + [snapEnabled, walls], + ); + + const snapPointFn = useCallback( + (point: Point): Point => { + if (!snapEnabled) return point; + // Grid snapping only (wall snapping is used specifically by door/window tools) + return snapPointToGrid(point, snapGranularity); + }, + [snapEnabled, snapGranularity], + ); + + return { + snapToGrid: snapToGridFn, + snapToWall: snapToWallFn, + snapPoint: snapPointFn, + }; +} diff --git a/apps/client/src/components/editor/layers/AnnotationLayer.tsx b/apps/client/src/components/editor/layers/AnnotationLayer.tsx new file mode 100644 index 0000000..94655fa --- /dev/null +++ b/apps/client/src/components/editor/layers/AnnotationLayer.tsx @@ -0,0 +1,148 @@ +import { memo, useMemo } from 'react'; +import { Layer, Text, Rect, Group, Line } from 'react-konva'; +import type { Point, Annotation, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared'; + +interface AnnotationLayerProps { + readonly annotations: readonly Annotation[]; + readonly electricalItems: readonly ElectricalItem[]; + readonly furnitureItems: readonly FurnitureItem[]; + readonly zoom: number; + readonly panOffset: Point; + readonly selectedIds: ReadonlySet; + readonly visible?: boolean; + readonly onDragEnd?: (id: string, x: number, y: number) => void; + readonly onDoubleClick?: (id: string) => void; + readonly onSelect?: (id: string) => void; +} + +const DEFAULT_FONT_SIZE = 14; +const DEFAULT_COLOR = '#333333'; +const SELECTED_COLOR = '#4c6ef5'; +const SELECTION_PADDING = 4; + +function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } { + return { + x: point.x * zoom + panOffset.x, + y: point.y * zoom + panOffset.y, + }; +} + +export const AnnotationLayer = memo(function AnnotationLayer({ + annotations, + electricalItems, + furnitureItems, + zoom, + panOffset, + selectedIds, + visible = true, + onDragEnd, + onDoubleClick, + onSelect, +}: AnnotationLayerProps) { + // Build item position lookup for attached annotations + const itemPositions = useMemo(() => { + const map = new Map(); + for (const item of electricalItems) { + map.set(item.id, { x: item.x, y: item.y }); + } + for (const item of furnitureItems) { + map.set(item.id, { x: item.x + item.width / 2, y: item.y + item.depth / 2 }); + } + return map; + }, [electricalItems, furnitureItems]); + + const renderedAnnotations = useMemo(() => { + if (!visible) return []; + return annotations; + }, [annotations, visible]); + + return ( + + {renderedAnnotations.map((annotation) => { + // Resolve position: if attached, offset from parent item + let worldX = annotation.x; + let worldY = annotation.y; + let parentScreen: { x: number; y: number } | null = null; + + if (annotation.attachedToId) { + const parentPos = itemPositions.get(annotation.attachedToId); + if (parentPos) { + worldX = parentPos.x + annotation.x; + worldY = parentPos.y + annotation.y; + parentScreen = toScreen(parentPos, zoom, panOffset); + } + } + + const screen = toScreen({ x: worldX, y: worldY }, zoom, panOffset); + const isSelected = selectedIds.has(annotation.id); + const fontSize = annotation.fontSize ?? DEFAULT_FONT_SIZE; + const color = isSelected ? SELECTED_COLOR : (annotation.color ?? DEFAULT_COLOR); + + return ( + + {/* Leader line from item to annotation */} + {parentScreen && ( + + )} + { + const node = e.target; + let newX = (node.x() - panOffset.x) / zoom; + let newY = (node.y() - panOffset.y) / zoom; + // For attached annotations, store as offset from parent + if (annotation.attachedToId) { + const parentPos = itemPositions.get(annotation.attachedToId); + if (parentPos) { + newX -= parentPos.x; + newY -= parentPos.y; + } + } + onDragEnd?.(annotation.id, newX, newY); + }} + onClick={() => onSelect?.(annotation.id)} + onDblClick={() => onDoubleClick?.(annotation.id)} + > + {/* Background */} + + {isSelected && ( + + )} + + + + ); + })} + + ); +}); diff --git a/apps/client/src/components/editor/layers/ElectricalLayer.tsx b/apps/client/src/components/editor/layers/ElectricalLayer.tsx new file mode 100644 index 0000000..75dfc3c --- /dev/null +++ b/apps/client/src/components/editor/layers/ElectricalLayer.tsx @@ -0,0 +1,170 @@ +import { memo, useMemo } from 'react'; +import { Layer, Group, Circle } from 'react-konva'; +import type { Point, ElectricalItem } from '@house-plan-maker/shared'; +import { + SingleOutletSymbol, + DoubleOutletSymbol, + GroundedOutletSymbol, + SingleSwitchSymbol, + DoubleSwitchSymbol, + DimmerSwitchSymbol, + JunctionBoxSymbol, + CeilingLightSymbol, + WallLightSymbol, + CableRouteSymbol, + getElectricalVariant, +} from '../symbols/electrical'; +import { getCableWaypoints } from '../tools/ElectricalTool'; +import { getLightCoverageRadius } from '../utils/lightCoverage'; + +interface ElectricalLayerProps { + readonly items: readonly ElectricalItem[]; + readonly zoom: number; + readonly panOffset: Point; + readonly selectedIds: ReadonlySet; + readonly visible?: boolean; +} + +const ELECTRICAL_COLOR = '#d63384'; +const SELECTED_COLOR = '#4c6ef5'; +const LIGHT_COVERAGE_COLOR = 'rgba(255, 235, 59, 0.12)'; +const LIGHT_COVERAGE_STROKE = 'rgba(255, 235, 59, 0.3)'; + +function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } { + return { + x: point.x * zoom + panOffset.x, + y: point.y * zoom + panOffset.y, + }; +} + +export const ElectricalLayer = memo(function ElectricalLayer({ + items, + zoom, + panOffset, + selectedIds, + visible = true, +}: ElectricalLayerProps) { + const scale = Math.max(0.6, Math.min(1.5, zoom / 100)); + + const renderedItems = useMemo(() => { + if (!visible) return []; + return items.filter((item) => item.type !== 'CABLE_ROUTE'); + }, [items, visible]); + + const cableItems = useMemo(() => { + if (!visible) return []; + return items.filter((item) => item.type === 'CABLE_ROUTE'); + }, [items, visible]); + + return ( + + {/* Cable routes first (below symbols) */} + {cableItems.map((item) => { + const waypoints = getCableWaypoints(item); + const screenPoints: number[] = []; + for (const wp of waypoints) { + const sp = toScreen(wp, zoom, panOffset); + screenPoints.push(sp.x, sp.y); + } + return ( + + ); + })} + + {/* Electrical symbols */} + {renderedItems.map((item) => { + const screen = toScreen({ x: item.x, y: item.y }, zoom, panOffset); + const isSelected = selectedIds.has(item.id); + const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLOR; + const variant = getElectricalVariant(item.metadata); + + return ( + + {/* Light coverage circle (only for selected light fixtures) */} + {isSelected && renderLightCoverage(item, zoom, panOffset)} + + {/* Symbol */} + {renderElectricalSymbol( + item.type, + variant, + screen.x, + screen.y, + item.rotation, + color, + scale, + )} + + ); + })} + + ); +}); + +function renderLightCoverage( + item: ElectricalItem, + zoom: number, + panOffset: Point, +): React.ReactNode { + const radius = getLightCoverageRadius(item); + if (radius == null) return null; + + const screen = toScreen({ x: item.x, y: item.y }, zoom, panOffset); + const radiusPx = radius * zoom; + + return ( + + ); +} + +function renderElectricalSymbol( + type: string, + variant: string, + x: number, + y: number, + rotation: number, + color: string, + scale: number, +): React.ReactNode { + switch (type) { + case 'OUTLET': + switch (variant) { + case 'double': + return ; + case 'grounded': + return ; + default: + return ; + } + case 'SWITCH': + switch (variant) { + case 'double': + return ; + case 'dimmer': + return ; + default: + return ; + } + case 'JUNCTION_BOX': + return ; + case 'LIGHT_CEILING': + return ; + case 'LIGHT_WALL': + return ; + default: + return null; + } +} diff --git a/apps/client/src/components/editor/layers/FurnitureLayer.tsx b/apps/client/src/components/editor/layers/FurnitureLayer.tsx new file mode 100644 index 0000000..00dfdc5 --- /dev/null +++ b/apps/client/src/components/editor/layers/FurnitureLayer.tsx @@ -0,0 +1,175 @@ +import { memo, useMemo } from 'react'; +import { Layer, Group, Rect, Line } from 'react-konva'; +import type { Point, FurnitureItem } from '@house-plan-maker/shared'; +import { BedSilhouette } from '../symbols/furniture/BedSilhouette'; +import { DeskSilhouette } from '../symbols/furniture/DeskSilhouette'; +import { WardrobeSilhouette } from '../symbols/furniture/WardrobeSilhouette'; +import { SofaSilhouette } from '../symbols/furniture/SofaSilhouette'; +import { TableSilhouette } from '../symbols/furniture/TableSilhouette'; +import { ChairSilhouette } from '../symbols/furniture/ChairSilhouette'; +import { ShelfSilhouette } from '../symbols/furniture/ShelfSilhouette'; +import { TvSilhouette } from '../symbols/furniture/TvSilhouette'; +import { findCollidingFurniture } from '../utils/collisionDetection'; + +interface FurnitureLayerProps { + readonly items: readonly FurnitureItem[]; + readonly zoom: number; + readonly panOffset: Point; + readonly selectedIds: ReadonlySet; + readonly visible?: boolean; +} + +const FURNITURE_COLOR = '#495057'; +const FURNITURE_FILL = 'rgba(222, 226, 230, 0.5)'; +const SELECTED_COLOR = '#4c6ef5'; +const SELECTED_FILL = 'rgba(76, 110, 245, 0.1)'; +const COLLISION_COLOR = '#e03131'; +const COLLISION_FILL = 'rgba(224, 49, 49, 0.1)'; + +function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } { + return { + x: point.x * zoom + panOffset.x, + y: point.y * zoom + panOffset.y, + }; +} + +export const FurnitureLayer = memo(function FurnitureLayer({ + items, + zoom, + panOffset, + selectedIds, + visible = true, +}: FurnitureLayerProps) { + const collidingIds = useMemo(() => findCollidingFurniture(items), [items]); + + return ( + + {items.map((item) => { + // x,y is the top-left corner; compute center for silhouette rendering + const centerX = item.x + item.width / 2; + const centerY = item.y + item.depth / 2; + const screenCenter = toScreen({ x: centerX, y: centerY }, zoom, panOffset); + const isSelected = selectedIds.has(item.id); + const isColliding = collidingIds.has(item.id); + const widthPx = item.width * zoom; + const depthPx = item.depth * zoom; + + const color = isColliding ? COLLISION_COLOR : isSelected ? SELECTED_COLOR : FURNITURE_COLOR; + const fillColor = isColliding ? COLLISION_FILL : isSelected ? SELECTED_FILL : FURNITURE_FILL; + + return ( + + {renderFurnitureSilhouette( + item.type, + screenCenter.x, + screenCenter.y, + widthPx, + depthPx, + item.rotation, + color, + fillColor, + )} + {/* Rotation handle indicator for selected furniture */} + {isSelected && ( + + )} + + ); + })} + + ); +}); + +interface RotationHandleProps { + readonly x: number; + readonly y: number; + readonly depthPx: number; + readonly rotation: number; +} + +function RotationHandle({ x, y, depthPx, rotation }: RotationHandleProps) { + const handleOffset = depthPx / 2 + 12; + const rad = (rotation * Math.PI) / 180; + const hx = x - Math.sin(rad) * handleOffset; + const hy = y - Math.cos(rad) * handleOffset; + + return ( + + {/* Line from center to handle */} + + {/* Handle circle */} + + + ); +} + +function renderFurnitureSilhouette( + type: string, + x: number, + y: number, + width: number, + depth: number, + rotation: number, + color: string, + fillColor: string, +): React.ReactNode { + const props = { x, y, width, depth, rotation, color, fillColor }; + + switch (type) { + case 'BED': + return ; + case 'DESK': + return ; + case 'WARDROBE': + return ; + case 'SOFA': + return ; + case 'TABLE': + return ; + case 'CHAIR': + return ; + case 'SHELF': + case 'BOOKCASE': + return ; + case 'NIGHTSTAND': + return ; + case 'DRESSER': + return ; + case 'TV': + return ; + default: + // Generic rectangle for OTHER / unknown + return ( + + ); + } +} diff --git a/apps/client/src/components/editor/layers/GridLayer.tsx b/apps/client/src/components/editor/layers/GridLayer.tsx new file mode 100644 index 0000000..b71dd09 --- /dev/null +++ b/apps/client/src/components/editor/layers/GridLayer.tsx @@ -0,0 +1,232 @@ +import { memo, useMemo } from 'react'; +import { Layer, Line, Text, Rect } from 'react-konva'; +import type { Point } from '@house-plan-maker/shared'; + +interface GridLayerProps { + readonly zoom: number; + readonly panOffset: Point; + readonly stageWidth: number; + readonly stageHeight: number; + readonly gridSize: number; + readonly visible: boolean; +} + +/** Color for thin grid lines. */ +const GRID_LINE_COLOR = '#e0e0e0'; +/** Color for major grid lines (every 1m). */ +const MAJOR_GRID_LINE_COLOR = '#c0c0c0'; +/** Color for ruler background. */ +const RULER_BG_COLOR = '#f5f5f5'; +/** Color for ruler text and ticks. */ +const RULER_TEXT_COLOR = '#666'; +/** Ruler size in pixels. */ +const RULER_SIZE = 24; + +export const GridLayer = memo(function GridLayer({ + zoom, + panOffset, + stageWidth, + stageHeight, + gridSize, + visible, +}: GridLayerProps) { + const gridLines = useMemo(() => { + if (!visible) return { lines: [], majorLines: [] }; + + const gridPixels = gridSize * zoom; + + // Skip grid lines if they would be too dense + if (gridPixels < 8) { + return { lines: [], majorLines: [] }; + } + + // Calculate visible world bounds + const worldLeft = -panOffset.x / zoom; + const worldTop = -panOffset.y / zoom; + const worldRight = (stageWidth - panOffset.x) / zoom; + const worldBottom = (stageHeight - panOffset.y) / zoom; + + // Snap to grid boundaries + const startX = Math.floor(worldLeft / gridSize) * gridSize; + const endX = Math.ceil(worldRight / gridSize) * gridSize; + const startY = Math.floor(worldTop / gridSize) * gridSize; + const endY = Math.ceil(worldBottom / gridSize) * gridSize; + + const lines: { points: number[]; isMajor: boolean }[] = []; + + // Steps per meter + const stepsPerMeter = Math.round(1 / gridSize); + + // Vertical lines + for (let x = startX; x <= endX; x += gridSize) { + const screenX = x * zoom + panOffset.x; + const roundedX = Math.round(x * 1000) / 1000; // avoid floating point noise + const isMajor = stepsPerMeter > 0 && Math.abs(roundedX % 1) < gridSize / 2; + lines.push({ + points: [screenX, 0, screenX, stageHeight], + isMajor, + }); + } + + // Horizontal lines + for (let y = startY; y <= endY; y += gridSize) { + const screenY = y * zoom + panOffset.y; + const roundedY = Math.round(y * 1000) / 1000; + const isMajor = stepsPerMeter > 0 && Math.abs(roundedY % 1) < gridSize / 2; + lines.push({ + points: [0, screenY, stageWidth, screenY], + isMajor, + }); + } + + const normal = lines.filter((l) => !l.isMajor); + const major = lines.filter((l) => l.isMajor); + + return { lines: normal, majorLines: major }; + }, [visible, zoom, panOffset, stageWidth, stageHeight, gridSize]); + + const rulerMarks = useMemo(() => { + // Determine ruler step: aim for marks every ~60-120 pixels + let rulerStep = 1; // meters + const pixelsPerMeter = zoom; + if (pixelsPerMeter < 30) rulerStep = 5; + else if (pixelsPerMeter < 60) rulerStep = 2; + else if (pixelsPerMeter > 300) rulerStep = 0.5; + else if (pixelsPerMeter > 600) rulerStep = 0.1; + + const worldLeft = -panOffset.x / zoom; + const worldTop = -panOffset.y / zoom; + const worldRight = (stageWidth - panOffset.x) / zoom; + const worldBottom = (stageHeight - panOffset.y) / zoom; + + const hMarks: { screenX: number; label: string }[] = []; + const startX = Math.floor(worldLeft / rulerStep) * rulerStep; + const endX = Math.ceil(worldRight / rulerStep) * rulerStep; + for (let x = startX; x <= endX; x += rulerStep) { + const screenX = x * zoom + panOffset.x; + if (screenX >= RULER_SIZE && screenX <= stageWidth) { + hMarks.push({ + screenX, + label: formatRulerLabel(x), + }); + } + } + + const vMarks: { screenY: number; label: string }[] = []; + const startY = Math.floor(worldTop / rulerStep) * rulerStep; + const endY = Math.ceil(worldBottom / rulerStep) * rulerStep; + for (let y = startY; y <= endY; y += rulerStep) { + const screenY = y * zoom + panOffset.y; + if (screenY >= RULER_SIZE && screenY <= stageHeight) { + vMarks.push({ + screenY, + label: formatRulerLabel(y), + }); + } + } + + return { hMarks, vMarks }; + }, [zoom, panOffset, stageWidth, stageHeight]); + + return ( + + {/* Grid lines */} + {visible && + gridLines.lines.map((line, i) => ( + + ))} + {visible && + gridLines.majorLines.map((line, i) => ( + + ))} + + {/* Horizontal ruler */} + + {rulerMarks.hMarks.map((mark, i) => ( + + ))} + {rulerMarks.hMarks.map((mark, i) => ( + + ))} + + {/* Vertical ruler */} + + {rulerMarks.vMarks.map((mark, i) => ( + + ))} + {rulerMarks.vMarks.map((mark, i) => ( + + ))} + + {/* Corner square */} + + + ); +}); + +function formatRulerLabel(meters: number): string { + const rounded = Math.round(meters * 100) / 100; + if (Number.isInteger(rounded)) return `${rounded}m`; + return `${rounded}`; +} diff --git a/apps/client/src/components/editor/layers/MeasureOverlayLayer.tsx b/apps/client/src/components/editor/layers/MeasureOverlayLayer.tsx new file mode 100644 index 0000000..c00cc52 --- /dev/null +++ b/apps/client/src/components/editor/layers/MeasureOverlayLayer.tsx @@ -0,0 +1,74 @@ +import { memo } from 'react'; +import { Layer, Line, Text, Circle } from 'react-konva'; +import type { Point } from '@house-plan-maker/shared'; +import type { MeasurementState } from '../types'; + +interface MeasureOverlayLayerProps { + readonly measurement: MeasurementState | null; + readonly zoom: number; + readonly panOffset: Point; +} + +function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } { + return { + x: point.x * zoom + panOffset.x, + y: point.y * zoom + panOffset.y, + }; +} + +const MEASURE_COLOR = '#e74c3c'; +const ENDPOINT_RADIUS = 4; + +export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({ + measurement, + zoom, + panOffset, +}: MeasureOverlayLayerProps) { + if (!measurement) return ; + + const start = toScreen(measurement.startPoint, zoom, panOffset); + const end = toScreen(measurement.endPoint, zoom, panOffset); + const midX = (start.x + end.x) / 2; + const midY = (start.y + end.y) / 2; + + const distanceM = measurement.distance; + const label = + distanceM >= 1 + ? `${distanceM.toFixed(2)} m` + : `${(distanceM * 100).toFixed(1)} cm`; + + return ( + + + + + {distanceM > 0.001 && ( + + )} + + ); +}); diff --git a/apps/client/src/components/editor/layers/MeasurementLayer.tsx b/apps/client/src/components/editor/layers/MeasurementLayer.tsx new file mode 100644 index 0000000..c431bbe --- /dev/null +++ b/apps/client/src/components/editor/layers/MeasurementLayer.tsx @@ -0,0 +1,300 @@ +import { memo, useMemo } from 'react'; +import { Layer, Line, Text, Group } from 'react-konva'; +import type { Point, Wall, WallOpening } from '@house-plan-maker/shared'; +import { wallLength, wallAngle, wallStartEnd } from '../utils/wallUtils'; + +interface MeasurementLayerProps { + readonly walls: readonly Wall[]; + readonly openings: readonly WallOpening[]; + readonly zoom: number; + readonly panOffset: Point; + readonly selectedIds: ReadonlySet; + readonly roomShape: readonly Point[]; +} + +const MEASUREMENT_COLOR = '#868e96'; +const MEASUREMENT_FONT_SIZE = 10; +const MEASUREMENT_OFFSET = 16; // pixels offset from wall +const MIN_ZOOM_FOR_MEASUREMENTS = 40; // hide at very low zoom +const SELECTED_MEASUREMENT_COLOR = '#4c6ef5'; + +export const MeasurementLayer = memo(function MeasurementLayer({ + walls, + openings, + zoom, + panOffset, + selectedIds, + roomShape, +}: MeasurementLayerProps) { + // Hide measurements at very low zoom levels + if (zoom < MIN_ZOOM_FOR_MEASUREMENTS) { + return ; + } + + return ( + + {/* Wall length annotations */} + {walls.map((wall) => ( + + ))} + + {/* Room overall dimensions */} + {roomShape.length >= 3 && ( + + )} + + {/* Distance from selected opening to wall ends */} + {openings + .filter((o) => selectedIds.has(o.id)) + .map((opening) => { + const wall = walls.find((w) => w.id === opening.wallId); + if (!wall) return null; + return ( + + ); + })} + + ); +}); + +// ── Wall length annotation ── + +interface WallMeasurementProps { + readonly wall: Wall; + readonly zoom: number; + readonly panOffset: Point; +} + +function WallMeasurement({ wall, zoom, panOffset }: WallMeasurementProps) { + const len = wallLength(wall); + if (len < 0.01) return null; + + const { start, end } = wallStartEnd(wall); + const angle = wallAngle(wall); + + // Midpoint of wall in screen coords + const midX = ((start.x + end.x) / 2) * zoom + panOffset.x; + const midY = ((start.y + end.y) / 2) * zoom + panOffset.y; + + // Offset perpendicular to wall + const offsetX = -Math.sin(angle) * MEASUREMENT_OFFSET; + const offsetY = Math.cos(angle) * MEASUREMENT_OFFSET; + + const label = formatMeasurement(len); + + return ( + + ); +} + +// ── Room overall dimensions ── + +interface RoomDimensionsProps { + readonly roomShape: readonly Point[]; + readonly zoom: number; + readonly panOffset: Point; +} + +function RoomDimensions({ roomShape, zoom, panOffset }: RoomDimensionsProps) { + const bounds = useMemo(() => { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of roomShape) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + return { minX, minY, maxX, maxY }; + }, [roomShape]); + + const width = bounds.maxX - bounds.minX; + const height = bounds.maxY - bounds.minY; + + if (width < 0.01 || height < 0.01) return null; + + const topY = bounds.minY * zoom + panOffset.y - 30; + const leftX = bounds.minX * zoom + panOffset.x - 30; + + // Horizontal dimension (width) + const hStartX = bounds.minX * zoom + panOffset.x; + const hEndX = bounds.maxX * zoom + panOffset.x; + const hMidX = (hStartX + hEndX) / 2; + + // Vertical dimension (height) + const vStartY = bounds.minY * zoom + panOffset.y; + const vEndY = bounds.maxY * zoom + panOffset.y; + const vMidY = (vStartY + vEndY) / 2; + + return ( + + {/* Horizontal dimension line */} + + {/* End ticks */} + + + + + {/* Vertical dimension line */} + + + + + + ); +} + +// ── Opening distance annotations ── + +interface OpeningDistancesProps { + readonly opening: WallOpening; + readonly wall: Wall; + readonly zoom: number; + readonly panOffset: Point; +} + +function OpeningDistances({ opening, wall, zoom, panOffset }: OpeningDistancesProps) { + const wLen = wallLength(wall); + const halfWidth = opening.width / 2; + const distToStart = opening.positionAlongWall - halfWidth; + const distToEnd = wLen - opening.positionAlongWall - halfWidth; + + const { start, end } = wallStartEnd(wall); + const angle = wallAngle(wall); + const dx = Math.cos(angle); + const dy = Math.sin(angle); + + // Perpendicular offset for annotation + const ox = -Math.sin(angle) * (MEASUREMENT_OFFSET + 12); + const oy = Math.cos(angle) * (MEASUREMENT_OFFSET + 12); + + const openingStartX = (start.x + dx * distToStart) * zoom + panOffset.x; + const openingStartY = (start.y + dy * distToStart) * zoom + panOffset.y; + const openingEndX = (start.x + dx * (opening.positionAlongWall + halfWidth)) * zoom + panOffset.x; + const openingEndY = (start.y + dy * (opening.positionAlongWall + halfWidth)) * zoom + panOffset.y; + const wallStartX = start.x * zoom + panOffset.x; + const wallStartY = start.y * zoom + panOffset.y; + const wallEndX = end.x * zoom + panOffset.x; + const wallEndY = end.y * zoom + panOffset.y; + + return ( + + {distToStart > 0.01 && ( + <> + + + + )} + {distToEnd > 0.01 && ( + <> + + + + )} + + ); +} + +function formatMeasurement(meters: number): string { + if (meters >= 1) { + const rounded = Math.round(meters * 100) / 100; + return `${rounded}m`; + } + const cm = Math.round(meters * 100); + return `${cm}cm`; +} diff --git a/apps/client/src/components/editor/layers/OpeningLayer.tsx b/apps/client/src/components/editor/layers/OpeningLayer.tsx new file mode 100644 index 0000000..ddc7f8d --- /dev/null +++ b/apps/client/src/components/editor/layers/OpeningLayer.tsx @@ -0,0 +1,357 @@ +import { memo, useMemo } from 'react'; +import { Layer, Line, Arc, Group } from 'react-konva'; +import type { Point, Wall, WallOpening, DoorOpenDirection } from '@house-plan-maker/shared'; +import { openingWorldPosition, wallAngle } from '../utils/wallUtils'; +import { polygonCentroid } from '../utils/geometry'; + +interface OpeningLayerProps { + readonly openings: readonly WallOpening[]; + readonly walls: readonly Wall[]; + readonly roomShape: readonly Point[]; + readonly zoom: number; + readonly panOffset: Point; + readonly selectedIds: ReadonlySet; + /** Optional preview for door/window placement. */ + readonly preview?: OpeningPreview | null; +} + +export interface OpeningPreview { + readonly wall: Wall; + readonly positionAlongWall: number; + readonly width: number; + readonly type: 'DOOR' | 'WINDOW'; + readonly isValid: boolean; +} + +const DOOR_COLOR = '#7048e8'; +const DOOR_ARC_COLOR = 'rgba(112, 72, 232, 0.15)'; +const WINDOW_COLOR = '#1c7ed6'; +const SELECTED_COLOR = '#4c6ef5'; +const PREVIEW_OPACITY = 0.5; +/** Gap in wall fill (background color) behind openings. */ +const WALL_GAP_COLOR = '#f8f9fa'; + +export const OpeningLayer = memo(function OpeningLayer({ + openings, + walls, + roomShape, + zoom, + panOffset, + selectedIds, + preview, +}: OpeningLayerProps) { + const wallMap = useMemo(() => { + const map = new Map(); + for (const wall of walls) { + map.set(wall.id, wall); + } + return map; + }, [walls]); + + const renderedOpenings = useMemo(() => { + return openings + .map((opening) => { + const wall = wallMap.get(opening.wallId); + if (!wall) return null; + + const pos = openingWorldPosition(opening, wall); + const isSelected = selectedIds.has(opening.id); + + return { + opening, + wall, + pos, + isSelected, + }; + }) + .filter(Boolean) as { + opening: WallOpening; + wall: Wall; + pos: ReturnType; + isSelected: boolean; + }[]; + }, [openings, wallMap, selectedIds]); + + // Compute room centroid to determine which side of each wall is "inward" + const centroid = useMemo( + () => (roomShape.length >= 3 ? polygonCentroid(roomShape) : { x: 0, y: 0 }), + [roomShape], + ); + + return ( + + {renderedOpenings.map(({ opening, wall, pos, isSelected }) => { + const screenCenter = toScreen(pos.center, zoom, panOffset); + const angle = wallAngle(wall); + const angleDeg = (angle * 180) / Math.PI; + const halfWidthPx = (opening.width / 2) * zoom; + + if (opening.type === 'DOOR') { + // Determine if arc should flip: the perpendicular "negative Y" in local wall + // coords should point toward the room center. If it doesn't, flip. + const nx = -Math.sin(angle); + const ny = Math.cos(angle); + const wallMidX = (wall.startX + wall.endX) / 2; + const wallMidY = (wall.startY + wall.endY) / 2; + const toCenterX = centroid.x - wallMidX; + const toCenterY = centroid.y - wallMidY; + // dot > 0 means the normal points toward center (inward) — no flip needed + const dot = nx * toCenterX + ny * toCenterY; + const arcFlip = dot < 0; + + return ( + + ); + } + + // WINDOW + return ( + + ); + })} + + {/* Placement preview */} + {preview && ( + + )} + + ); +}); + +// ── Door Symbol (arc showing swing direction) ── + +interface DoorSymbolProps { + readonly x: number; + readonly y: number; + readonly angleDeg: number; + readonly halfWidthPx: number; + readonly wallThicknessPx: number; + readonly isSelected: boolean; + readonly openDirection?: DoorOpenDirection; + /** Flip the arc direction when the default "inward" points outside the room. */ + readonly flipInward?: boolean; +} + +function DoorSymbol({ x, y, angleDeg, halfWidthPx, wallThicknessPx, isSelected, openDirection = 'LEFT', flipInward = false }: DoorSymbolProps) { + const color = isSelected ? SELECTED_COLOR : DOOR_COLOR; + const doorWidthPx = halfWidthPx * 2; + + const isRight = openDirection === 'RIGHT'; + const isOutward = openDirection === 'OUTWARD'; + + // Mirror the entire door group for RIGHT hinge + const groupScaleX = isRight ? -1 : 1; + + // Hinge is always at -halfWidthPx in local coords (left edge); mirror flips it + const hingeX = -halfWidthPx; + + // Determine inward direction: flipInward corrects for wall orientation + const inwardIsNegativeY = flipInward ? false : true; + const swingInward = !isOutward; + const leafGoesNegativeY = swingInward ? inwardIsNegativeY : !inwardIsNegativeY; + + const leafEndY = leafGoesNegativeY ? -doorWidthPx : doorWidthPx; + const arcRotation = leafGoesNegativeY ? -90 : 0; + + return ( + + {/* Wall gap background */} + + {/* Door leaf line */} + + {/* Arc showing swing */} + + {/* Door frame marks */} + + + + ); +} + +// ── Window Symbol (parallel lines in wall) ── + +interface WindowSymbolProps { + readonly x: number; + readonly y: number; + readonly angleDeg: number; + readonly halfWidthPx: number; + readonly wallThicknessPx: number; + readonly isSelected: boolean; +} + +function WindowSymbol({ x, y, angleDeg, halfWidthPx, wallThicknessPx, isSelected }: WindowSymbolProps) { + const color = isSelected ? SELECTED_COLOR : WINDOW_COLOR; + const halfThick = wallThicknessPx / 2; + const innerOffset = halfThick * 0.3; + + return ( + + {/* Wall gap background */} + + {/* Outer parallel lines */} + + + {/* Inner parallel lines (glass representation) */} + + + {/* End caps */} + + + + ); +} + +// ── Preview Symbol ── + +interface PreviewSymbolProps { + readonly wall: Wall; + readonly positionAlongWall: number; + readonly width: number; + readonly type: 'DOOR' | 'WINDOW'; + readonly isValid: boolean; + readonly zoom: number; + readonly panOffset: Point; +} + +function PreviewSymbol({ wall, positionAlongWall, width, type, isValid: _isValid, zoom, panOffset }: PreviewSymbolProps) { + const tempOpening: WallOpening = { + id: 'preview', + roomId: '', + wallId: wall.id, + type, + positionAlongWall, + width, + height: 0, + elevationFromFloor: 0, + openDirection: 'LEFT', + }; + + const pos = openingWorldPosition(tempOpening, wall); + const screenCenter = toScreen(pos.center, zoom, panOffset); + const angleDeg = (wallAngle(wall) * 180) / Math.PI; + const halfWidthPx = (width / 2) * zoom; + const wallThicknessPx = wall.thickness * zoom; + return ( + + {type === 'DOOR' ? ( + + ) : ( + + )} + + ); +} + +function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } { + return { + x: point.x * zoom + panOffset.x, + y: point.y * zoom + panOffset.y, + }; +} diff --git a/apps/client/src/components/editor/layers/RoomLabelLayer.tsx b/apps/client/src/components/editor/layers/RoomLabelLayer.tsx new file mode 100644 index 0000000..824b6d9 --- /dev/null +++ b/apps/client/src/components/editor/layers/RoomLabelLayer.tsx @@ -0,0 +1,97 @@ +import { useMemo } from 'react'; +import { Layer, Group, Rect, Text } from 'react-konva'; +import type { Point } from '@house-plan-maker/shared'; +import { polygonArea, polygonCentroid } from '../utils/geometry'; + +interface RoomLabelLayerProps { + readonly roomName: string; + readonly roomShape: readonly Point[]; + readonly zoom: number; + readonly panOffset: Point; +} + +const LABEL_FONT_SIZE = 13; +const AREA_FONT_SIZE = 11; +const LABEL_COLOR = '#343a40'; +const AREA_COLOR = '#868e96'; +const BG_COLOR = 'rgba(255, 255, 255, 0.75)'; +const BG_PADDING_X = 8; +const BG_PADDING_Y = 4; +const LINE_SPACING = 4; +const MIN_ZOOM_FOR_LABELS = 30; + +export function RoomLabelLayer({ + roomName, + roomShape, + zoom, + panOffset, +}: RoomLabelLayerProps) { + const area = useMemo( + () => (roomShape.length >= 3 ? polygonArea(roomShape) : 0), + [roomShape], + ); + + const centroid = useMemo( + () => (roomShape.length >= 3 ? polygonCentroid(roomShape) : null), + [roomShape], + ); + + if (!centroid || zoom < MIN_ZOOM_FOR_LABELS) { + return ; + } + + const areaText = `${(Math.round(area * 100) / 100).toFixed(1)} m\u00B2`; + + // Position in screen coordinates + const screenX = centroid.x * zoom + panOffset.x; + const screenY = centroid.y * zoom + panOffset.y; + + // Estimate text widths (approximate: ~7px per char at font size 13, ~6px at 11) + const nameWidth = roomName.length * 7.5; + const areaWidth = areaText.length * 6.5; + const maxWidth = Math.max(nameWidth, areaWidth); + const totalHeight = LABEL_FONT_SIZE + LINE_SPACING + AREA_FONT_SIZE; + + const bgWidth = maxWidth + BG_PADDING_X * 2; + const bgHeight = totalHeight + BG_PADDING_Y * 2; + + return ( + + + {/* Semi-transparent background */} + + {/* Room name */} + + {/* Area */} + + + + ); +} diff --git a/apps/client/src/components/editor/layers/SelectionLayer.tsx b/apps/client/src/components/editor/layers/SelectionLayer.tsx new file mode 100644 index 0000000..08810d2 --- /dev/null +++ b/apps/client/src/components/editor/layers/SelectionLayer.tsx @@ -0,0 +1,115 @@ +import { memo } from 'react'; +import { Layer, Rect } from 'react-konva'; +import type { Point } from '@house-plan-maker/shared'; + +interface SelectionLayerProps { + readonly zoom: number; + readonly panOffset: Point; + /** Bounding box in world coordinates of current selection. */ + readonly selectionBox: { + x: number; + y: number; + width: number; + height: number; + } | null; + /** Drag selection rectangle in world coordinates (while dragging). */ + readonly dragRect: { + x: number; + y: number; + width: number; + height: number; + } | null; +} + +const SELECTION_STROKE = '#4c6ef5'; +const SELECTION_FILL = 'rgba(76, 110, 245, 0.08)'; +const DRAG_RECT_STROKE = '#4c6ef5'; +const DRAG_RECT_FILL = 'rgba(76, 110, 245, 0.1)'; +/** Padding around selection box in meters. */ +const PADDING = 0.05; + +export const SelectionLayer = memo(function SelectionLayer({ + zoom, + panOffset, + selectionBox, + dragRect, +}: SelectionLayerProps) { + return ( + + {/* Selection bounding box with resize handles */} + {selectionBox && ( + + )} + + {/* Drag selection rectangle */} + {dragRect && ( + + )} + + ); +}); + +interface SelectionBoundingBoxProps { + readonly box: { x: number; y: number; width: number; height: number }; + readonly zoom: number; + readonly panOffset: Point; +} + +function SelectionBoundingBox({ box, zoom, panOffset }: SelectionBoundingBoxProps) { + const x = (box.x - PADDING) * zoom + panOffset.x; + const y = (box.y - PADDING) * zoom + panOffset.y; + const w = (box.width + PADDING * 2) * zoom; + const h = (box.height + PADDING * 2) * zoom; + + return ( + <> + {/* Dashed bounding rectangle */} + + + ); +} + +interface DragSelectionRectProps { + readonly rect: { x: number; y: number; width: number; height: number }; + readonly zoom: number; + readonly panOffset: Point; +} + +function DragSelectionRect({ rect, zoom, panOffset }: DragSelectionRectProps) { + const x = rect.x * zoom + panOffset.x; + const y = rect.y * zoom + panOffset.y; + const w = rect.width * zoom; + const h = rect.height * zoom; + + return ( + + ); +} diff --git a/apps/client/src/components/editor/layers/WallLayer.tsx b/apps/client/src/components/editor/layers/WallLayer.tsx new file mode 100644 index 0000000..23773a4 --- /dev/null +++ b/apps/client/src/components/editor/layers/WallLayer.tsx @@ -0,0 +1,236 @@ +import { memo, useMemo } from 'react'; +import { Layer, Line, Group } from 'react-konva'; +import type { Point, Wall } from '@house-plan-maker/shared'; +import { polygonCentroid } from '../utils/geometry'; + +interface WallLayerProps { + readonly walls: readonly Wall[]; + readonly roomShape: readonly Point[]; + readonly zoom: number; + readonly panOffset: Point; + readonly selectedIds: ReadonlySet; + readonly plinthThickness?: number; +} + +/** Wall fill color. */ +const WALL_FILL = '#495057'; +/** Wall stroke color. */ +const WALL_STROKE = '#343a40'; +/** Room interior fill color. */ +const ROOM_FILL = '#f8f9fa'; +/** Room interior stroke. */ +const ROOM_STROKE = '#dee2e6'; +/** Selected wall highlight. */ +const WALL_SELECTED_STROKE = '#4c6ef5'; + +/** + * Offset a polygon outward by a fixed distance. + * Uses the angle bisector at each vertex to push the point outward. + */ +function offsetPolygonOutward(shape: readonly Point[], thickness: number, centroid: Point): Point[] { + const n = shape.length; + if (n < 3) return [...shape]; + + const result: Point[] = []; + + for (let i = 0; i < n; i++) { + const prev = shape[(i - 1 + n) % n]; + const curr = shape[i]; + const next = shape[(i + 1) % n]; + + // Edge vectors + const e1x = curr.x - prev.x; + const e1y = curr.y - prev.y; + const e2x = next.x - curr.x; + const e2y = next.y - curr.y; + + // Outward normals for each edge (perpendicular, pointing away from centroid) + let n1x = -e1y; + let n1y = e1x; + // Normalize + const len1 = Math.sqrt(n1x * n1x + n1y * n1y) || 1; + n1x /= len1; + n1y /= len1; + + let n2x = -e2y; + let n2y = e2x; + const len2 = Math.sqrt(n2x * n2x + n2y * n2y) || 1; + n2x /= len2; + n2y /= len2; + + // Ensure normals point away from centroid + const midX = curr.x; + const midY = curr.y; + const toCenterX = centroid.x - midX; + const toCenterY = centroid.y - midY; + + if (n1x * toCenterX + n1y * toCenterY > 0) { + n1x = -n1x; + n1y = -n1y; + } + if (n2x * toCenterX + n2y * toCenterY > 0) { + n2x = -n2x; + n2y = -n2y; + } + + // Average bisector direction + let bx = n1x + n2x; + let by = n1y + n2y; + const bLen = Math.sqrt(bx * bx + by * by); + + if (bLen < 0.001) { + // Parallel edges — just use one normal + bx = n1x; + by = n1y; + } else { + bx /= bLen; + by /= bLen; + // Scale by 1/cos(half-angle) to maintain thickness at the corner + const cosHalf = bx * n1x + by * n1y; + const scale = cosHalf > 0.1 ? 1 / cosHalf : 1; + bx *= Math.min(scale, 3); // cap to prevent extreme spikes + by *= Math.min(scale, 3); + } + + result.push({ + x: curr.x + bx * thickness, + y: curr.y + by * thickness, + }); + } + + return result; +} + +/** Plinth color. */ +const PLINTH_COLOR = '#8b7355'; + +export const WallLayer = memo(function WallLayer({ + walls, + roomShape, + zoom, + panOffset, + selectedIds, + plinthThickness = 0.01, +}: WallLayerProps) { + // Get wall thickness (use first wall's thickness as representative) + const wallThickness = walls.length > 0 ? walls[0].thickness : 0.1; + + // Convert room shape to screen coordinates + const roomShapeScreen = useMemo(() => { + if (roomShape.length < 3) return []; + return roomShape.flatMap((p) => [ + p.x * zoom + panOffset.x, + p.y * zoom + panOffset.y, + ]); + }, [roomShape, zoom, panOffset]); + + // Compute outer wall boundary (room shape offset outward by wall thickness) + const outerWallScreen = useMemo(() => { + if (roomShape.length < 3) return []; + const centroid = polygonCentroid(roomShape); + const outer = offsetPolygonOutward(roomShape, wallThickness, centroid); + return outer.flatMap((p) => [ + p.x * zoom + panOffset.x, + p.y * zoom + panOffset.y, + ]); + }, [roomShape, wallThickness, zoom, panOffset]); + + // Compute inner plinth boundary (room shape offset inward by plinth thickness) + const plinthScreen = useMemo(() => { + if (roomShape.length < 3 || plinthThickness <= 0) return []; + const centroid = polygonCentroid(roomShape); + // Offset inward (toward centroid) = negative outward offset + const inner = offsetPolygonOutward(roomShape, -plinthThickness, centroid); + return inner.flatMap((p) => [ + p.x * zoom + panOffset.x, + p.y * zoom + panOffset.y, + ]); + }, [roomShape, plinthThickness, zoom, panOffset]); + + // Selected wall segments for highlighting + const selectedWallSegments = useMemo(() => { + return walls + .filter((w) => selectedIds.has(w.id)) + .map((wall) => { + const points = [ + wall.startX * zoom + panOffset.x, + wall.startY * zoom + panOffset.y, + wall.endX * zoom + panOffset.x, + wall.endY * zoom + panOffset.y, + ]; + return { wall, points }; + }); + }, [walls, selectedIds, zoom, panOffset]); + + return ( + + {/* Room interior fill */} + {roomShapeScreen.length >= 6 && ( + + )} + + {/* Outer wall boundary — single continuous polygon, no corner gaps */} + {outerWallScreen.length >= 6 && ( + + {/* Outer fill */} + + {/* Cut out room interior by drawing room shape on top with room fill */} + + + )} + + {/* Plinth strip along inside of walls */} + {plinthScreen.length >= 6 && roomShapeScreen.length >= 6 && ( + + {/* Draw room shape filled with plinth color */} + + {/* Cut out inner area (room minus plinth) */} + + + )} + + {/* Selected wall highlights */} + {selectedWallSegments.map(({ wall, points }) => ( + + ))} + + ); +}); diff --git a/apps/client/src/components/editor/overlays/ScaleBar.tsx b/apps/client/src/components/editor/overlays/ScaleBar.tsx new file mode 100644 index 0000000..b8ff8ba --- /dev/null +++ b/apps/client/src/components/editor/overlays/ScaleBar.tsx @@ -0,0 +1,60 @@ +import styles from './scale-bar.module.css'; + +interface ScaleBarProps { + readonly zoom: number; +} + +/** Fixed pixel width of the scale bar on screen. */ +const BAR_WIDTH_PX = 100; + +/** + * Compute a "nice" real-world distance for the scale bar + * and the corresponding pixel width to display. + */ +function computeScaleInfo(zoom: number): { label: string; barPx: number } { + // zoom = pixels per meter + // BAR_WIDTH_PX pixels = BAR_WIDTH_PX / zoom meters + const rawMeters = BAR_WIDTH_PX / zoom; + + // Find the nearest "nice" value + const niceValues = [ + 0.01, 0.02, 0.05, 0.1, 0.2, 0.25, 0.5, + 1, 2, 5, 10, 20, 50, 100, + ]; + + let bestValue = niceValues[0]; + let bestDiff = Math.abs(rawMeters - bestValue); + for (const v of niceValues) { + const diff = Math.abs(rawMeters - v); + if (diff < bestDiff) { + bestDiff = diff; + bestValue = v; + } + } + + const barPx = bestValue * zoom; + + let label: string; + if (bestValue >= 1) { + label = `${bestValue}m`; + } else { + label = `${Math.round(bestValue * 100)}cm`; + } + + return { label, barPx }; +} + +export function ScaleBar({ zoom }: ScaleBarProps) { + const { label, barPx } = computeScaleInfo(zoom); + + return ( +
+
+
+
+
+
+ {label} +
+ ); +} diff --git a/apps/client/src/components/editor/overlays/scale-bar.module.css b/apps/client/src/components/editor/overlays/scale-bar.module.css new file mode 100644 index 0000000..4f2c392 --- /dev/null +++ b/apps/client/src/components/editor/overlays/scale-bar.module.css @@ -0,0 +1,41 @@ +.container { + position: absolute; + bottom: 16px; + left: 16px; + display: flex; + flex-direction: column; + align-items: center; + pointer-events: none; + user-select: none; + z-index: 10; +} + +.bar { + display: flex; + align-items: center; + height: 12px; + position: relative; +} + +.line { + flex: 1; + height: 2px; + background-color: var(--color-text-secondary, #495057); +} + +.tickLeft, +.tickRight { + width: 2px; + height: 12px; + background-color: var(--color-text-secondary, #495057); + flex-shrink: 0; +} + +.label { + margin-top: 2px; + font-size: 10px; + font-weight: 600; + color: var(--color-text-secondary, #495057); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} diff --git a/apps/client/src/components/editor/panels/CableLengthStatus.tsx b/apps/client/src/components/editor/panels/CableLengthStatus.tsx new file mode 100644 index 0000000..2f707c5 --- /dev/null +++ b/apps/client/src/components/editor/panels/CableLengthStatus.tsx @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ElectricalItem } from '@house-plan-maker/shared'; +import { computeCableRouteLength } from '../tools/ElectricalTool'; +import styles from './cable-length-status.module.css'; + +interface CableLengthStatusProps { + readonly electricalItems: readonly ElectricalItem[]; +} + +/** Status bar component showing total cable route length. */ +export function CableLengthStatus({ electricalItems }: CableLengthStatusProps) { + const { t } = useTranslation(); + + const totalLength = useMemo( + () => computeCableRouteLength(electricalItems), + [electricalItems], + ); + + const hasCables = electricalItems.some((i) => i.type === 'CABLE_ROUTE'); + + if (!hasCables) return null; + + return ( +
+ {t('cableLength.label')} + + {totalLength.toFixed(2)}m + +
+ ); +} diff --git a/apps/client/src/components/editor/panels/ElectricalPalette.tsx b/apps/client/src/components/editor/panels/ElectricalPalette.tsx new file mode 100644 index 0000000..ac96bb9 --- /dev/null +++ b/apps/client/src/components/editor/panels/ElectricalPalette.tsx @@ -0,0 +1,84 @@ +import { useMemo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ELECTRICAL_SYMBOL_DEFS, + type ElectricalSymbolDef, +} from '../symbols/electrical'; +import styles from './electrical-palette.module.css'; + +interface ElectricalPaletteProps { + readonly selectedIndex: number | null; + readonly onSelect: (index: number) => void; +} + +interface CategoryGroup { + readonly name: string; + readonly nameKey: string; + readonly icon: string; + readonly items: readonly { readonly def: ElectricalSymbolDef; readonly index: number }[]; +} + +const CATEGORY_META: Record = { + outlet: { nameKey: 'electrical.outlets', icon: '\u26A1' }, + switch: { nameKey: 'electrical.switches', icon: '\u{1F50C}' }, + junction: { nameKey: 'electrical.junction', icon: '\u2B1C' }, + light: { nameKey: 'electrical.lights', icon: '\u{1F4A1}' }, + cable: { nameKey: 'electrical.cable', icon: '\u{1F517}' }, +}; + +export function ElectricalPalette({ selectedIndex, onSelect }: ElectricalPaletteProps) { + const { t } = useTranslation(); + + const categories = useMemo(() => { + const groups = new Map(); + + ELECTRICAL_SYMBOL_DEFS.forEach((def, index) => { + const list = groups.get(def.category) ?? []; + list.push({ def, index }); + groups.set(def.category, list); + }); + + return Array.from(groups.entries()).map(([cat, items]) => ({ + name: cat, + nameKey: CATEGORY_META[cat]?.nameKey ?? cat, + icon: CATEGORY_META[cat]?.icon ?? '', + items, + })); + }, []); + + const handleSelect = useCallback( + (index: number) => { + onSelect(index); + }, + [onSelect], + ); + + return ( +
+
{t('electrical.title')}
+ {categories.map((cat) => ( +
+
+ {cat.icon} {t(cat.nameKey)} +
+
+ {cat.items.map(({ def, index }) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/apps/client/src/components/editor/panels/FurniturePalette.tsx b/apps/client/src/components/editor/panels/FurniturePalette.tsx new file mode 100644 index 0000000..ca9a97b --- /dev/null +++ b/apps/client/src/components/editor/panels/FurniturePalette.tsx @@ -0,0 +1,65 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FURNITURE_DEFS, type FurnitureDef } from '../symbols/furniture'; +import styles from './furniture-palette.module.css'; + +interface FurniturePaletteProps { + readonly selectedIndex: number | null; + readonly onSelect: (index: number) => void; +} + +export function FurniturePalette({ selectedIndex, onSelect }: FurniturePaletteProps) { + const { t } = useTranslation(); + + const handleSelect = useCallback( + (index: number) => { + onSelect(index); + }, + [onSelect], + ); + + return ( +
+
{t('furniture.title')}
+
+ {FURNITURE_DEFS.map((def, index) => ( + + ))} +
+
+ ); +} + +interface FurnitureItemBtnProps { + readonly def: FurnitureDef; + readonly index: number; + readonly isActive: boolean; + readonly onSelect: (index: number) => void; +} + +function FurnitureItemBtn({ def, index, isActive, onSelect }: FurnitureItemBtnProps) { + return ( + + ); +} diff --git a/apps/client/src/components/editor/panels/cable-length-status.module.css b/apps/client/src/components/editor/panels/cable-length-status.module.css new file mode 100644 index 0000000..02c9674 --- /dev/null +++ b/apps/client/src/components/editor/panels/cable-length-status.module.css @@ -0,0 +1,20 @@ +.status { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + background-color: var(--color-bg-elevated); + border-top: 1px solid var(--color-border); + min-height: 28px; +} + +.label { + font-weight: var(--font-weight-medium); +} + +.value { + font-variant-numeric: tabular-nums; + color: var(--color-text-primary); +} diff --git a/apps/client/src/components/editor/panels/electrical-palette.module.css b/apps/client/src/components/editor/panels/electrical-palette.module.css new file mode 100644 index 0000000..18ee489 --- /dev/null +++ b/apps/client/src/components/editor/panels/electrical-palette.module.css @@ -0,0 +1,86 @@ +.palette { + position: absolute; + top: 0; + left: 0; + z-index: var(--z-dropdown); + width: 220px; + max-height: 100%; + background-color: var(--color-bg-elevated); + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + border-radius: 0 0 var(--radius-md) 0; + overflow-y: auto; + display: flex; + flex-direction: column; + opacity: 0.95; + box-shadow: var(--shadow-lg); +} + +.header { + padding: var(--space-3) var(--space-4); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border); + letter-spacing: var(--letter-spacing-wide); + text-transform: uppercase; +} + +.category { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.categoryTitle { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + margin-bottom: var(--space-1); +} + +.itemGrid { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} + +.itemBtn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: var(--space-1) var(--space-2); + border: 1px solid transparent; + border-radius: var(--radius-md); + background: none; + cursor: pointer; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + transition: all var(--transition-fast); + font-family: var(--font-family); + min-width: 60px; +} + +.itemBtn:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.itemBtnActive { + background-color: var(--color-accent-50); + border-color: var(--color-accent-200); + color: var(--color-accent-700); +} + +.itemIcon { + font-size: 18px; + line-height: 1; +} + +.itemLabel { + font-size: var(--font-size-xs); + text-align: center; + line-height: 1.2; +} diff --git a/apps/client/src/components/editor/panels/furniture-palette.module.css b/apps/client/src/components/editor/panels/furniture-palette.module.css new file mode 100644 index 0000000..55fcc8e --- /dev/null +++ b/apps/client/src/components/editor/panels/furniture-palette.module.css @@ -0,0 +1,87 @@ +.palette { + position: absolute; + top: 0; + left: 0; + z-index: var(--z-dropdown); + width: 220px; + max-height: 100%; + background-color: var(--color-bg-elevated); + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + border-radius: 0 0 var(--radius-md) 0; + overflow-y: auto; + display: flex; + flex-direction: column; + opacity: 0.95; + box-shadow: var(--shadow-lg); +} + +.header { + padding: var(--space-3) var(--space-4); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border); + letter-spacing: var(--letter-spacing-wide); + text-transform: uppercase; +} + +.itemList { + display: flex; + flex-direction: column; + padding: var(--space-1); +} + +.itemBtn { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-md); + background: none; + cursor: pointer; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + transition: all var(--transition-fast); + font-family: var(--font-family); + text-align: left; + width: 100%; +} + +.itemBtn:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.itemBtnActive { + background-color: var(--color-accent-50); + border-color: var(--color-accent-200); + color: var(--color-accent-700); +} + +.itemIcon { + font-size: 16px; + line-height: 1; + flex-shrink: 0; +} + +.itemInfo { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.itemLabel { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.itemDims { + font-size: 11px; + color: var(--color-text-muted); +} diff --git a/apps/client/src/components/editor/projection/ProjectionDoor.tsx b/apps/client/src/components/editor/projection/ProjectionDoor.tsx new file mode 100644 index 0000000..cbd84ec --- /dev/null +++ b/apps/client/src/components/editor/projection/ProjectionDoor.tsx @@ -0,0 +1,146 @@ +import { Group, Rect, Line, Text } from 'react-konva'; +import type { ProjectedOpening } from '../utils/projectionMapping'; +import { projectionToPixel } from '../utils/projectionMapping'; +import type { DoorOpenDirection } from '@house-plan-maker/shared'; + +interface ProjectionDoorProps { + readonly projected: ProjectedOpening; + readonly wallHeight: number; + readonly scale: number; + readonly padding: number; + readonly isSelected: boolean; + readonly isDragging?: boolean; + readonly dragAlongWall?: number; + readonly onClick: () => void; + readonly onDragStart?: (openingId: string, evt: MouseEvent) => void; +} + +/** + * Build polyline points for a quarter-circle arc used as the door swing indicator. + * The arc origin and sweep direction depend on the open direction. + */ +function buildSwingArcPoints( + topLeftX: number, + topLeftY: number, + pxWidth: number, + pxHeight: number, + openDirection: DoorOpenDirection, +): number[] { + const arcRadius = pxWidth * 0.8; + const steps = 12; + const points: number[] = []; + + // Hinge at left or right bottom corner; arc sweeps accordingly + const hingeLeft = openDirection === 'LEFT' || openDirection === 'INWARD' || openDirection === 'OUTWARD'; + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const angle = (Math.PI / 2) * t; + + if (hingeLeft) { + // Hinge at bottom-left, arc sweeps rightward and upward + points.push( + topLeftX + Math.sin(angle) * arcRadius, + topLeftY + pxHeight - Math.cos(angle) * arcRadius, + ); + } else { + // Hinge at bottom-right, arc sweeps leftward and upward + points.push( + topLeftX + pxWidth - Math.sin(angle) * arcRadius, + topLeftY + pxHeight - Math.cos(angle) * arcRadius, + ); + } + } + + return points; +} + +/** Render a door in wall elevation view. */ +export function ProjectionDoor({ + projected, + wallHeight, + scale, + padding, + isSelected, + isDragging = false, + dragAlongWall, + onClick, + onDragStart, +}: ProjectionDoorProps) { + const { rect, opening } = projected; + const openDirection: DoorOpenDirection = opening.openDirection ?? 'LEFT'; + + const displayX = isDragging && dragAlongWall != null + ? dragAlongWall - opening.width / 2 + : rect.x; + + const topLeft = projectionToPixel(displayX, rect.y + rect.height, wallHeight, scale, padding); + const pxWidth = rect.width * scale; + const pxHeight = rect.height * scale; + + const arcPoints = buildSwingArcPoints(topLeft.x, topLeft.y, pxWidth, pxHeight, openDirection); + + // Door leaf line position depends on hinge side + const isHingeLeft = openDirection === 'LEFT' || openDirection === 'INWARD' || openDirection === 'OUTWARD'; + const leafPoints = isHingeLeft + ? [topLeft.x, topLeft.y, topLeft.x, topLeft.y + pxHeight] + : [topLeft.x + pxWidth, topLeft.y, topLeft.x + pxWidth, topLeft.y + pxHeight]; + + return ( + { + if (onDragStart && e.evt.button === 0) { + onDragStart(opening.id, e.evt); + } + }} + > + {/* Drag ghost outline */} + {isDragging && ( + + )} + {/* Door opening (gap in wall) */} + + {/* Door leaf line (vertical line showing hinge side) */} + + {/* Door swing indicator arc */} + + {/* Door label */} + + + ); +} diff --git a/apps/client/src/components/editor/projection/ProjectionElectrical.tsx b/apps/client/src/components/editor/projection/ProjectionElectrical.tsx new file mode 100644 index 0000000..6b91d45 --- /dev/null +++ b/apps/client/src/components/editor/projection/ProjectionElectrical.tsx @@ -0,0 +1,172 @@ +import { Group, Circle, Line, Rect, Text } from 'react-konva'; +import type { ProjectedElectrical } from '../utils/projectionMapping'; +import { projectionToPixel } from '../utils/projectionMapping'; + +interface ProjectionElectricalProps { + readonly projected: ProjectedElectrical; + readonly wallHeight: number; + readonly scale: number; + readonly padding: number; + readonly isSelected: boolean; + readonly isDragging?: boolean; + readonly dragFromFloor?: number; + readonly dragAlongWall?: number; + readonly onClick: () => void; + readonly onDragStart?: (itemId: string, evt: MouseEvent) => void; +} + +const SYMBOL_SIZE = 12; + +/** Render a wall-mounted electrical item in wall elevation view. */ +export function ProjectionElectrical({ + projected, + wallHeight, + scale, + padding, + isSelected, + isDragging = false, + dragFromFloor, + dragAlongWall, + onClick, + onDragStart, +}: ProjectionElectricalProps) { + const { position, item } = projected; + + const displayFromFloor = isDragging && dragFromFloor != null ? dragFromFloor : position.fromFloor; + const displayAlongWall = isDragging && dragAlongWall != null ? dragAlongWall : position.alongWall; + + const center = projectionToPixel( + displayAlongWall, + displayFromFloor, + wallHeight, + scale, + padding, + ); + + const strokeColor = isSelected ? '#2563eb' : '#64748b'; + const fillColor = isSelected ? '#dbeafe' : '#f1f5f9'; + const half = SYMBOL_SIZE / 2; + + return ( + { + if (onDragStart && e.evt.button === 0) { + onDragStart(item.id, e.evt); + } + }} + style={{ cursor: onDragStart ? 'grab' : 'default' }} + > + {/* Drag ghost outline */} + {isDragging && ( + + )} + {item.type === 'OUTLET' && ( + <> + {/* IEC outlet symbol: circle with two horizontal lines */} + + + + + )} + {item.type === 'SWITCH' && ( + <> + {/* IEC switch symbol: circle with diagonal line */} + + + + )} + {item.type === 'LIGHT_WALL' && ( + <> + {/* Wall light: semicircle shape */} + + + + + )} + {/* Fallback for other wall-mounted types */} + {item.type !== 'OUTLET' && item.type !== 'SWITCH' && item.type !== 'LIGHT_WALL' && ( + + )} + {/* Type label below symbol */} + + + ); +} diff --git a/apps/client/src/components/editor/projection/ProjectionFurniture.tsx b/apps/client/src/components/editor/projection/ProjectionFurniture.tsx new file mode 100644 index 0000000..d2cd748 --- /dev/null +++ b/apps/client/src/components/editor/projection/ProjectionFurniture.tsx @@ -0,0 +1,65 @@ +import { Group, Rect, Text } from 'react-konva'; +import type { ProjectedFurniture } from '../utils/projectionMapping'; +import { projectionToPixel } from '../utils/projectionMapping'; + +interface ProjectionFurnitureProps { + readonly projected: ProjectedFurniture; + readonly wallHeight: number; + readonly scale: number; + readonly padding: number; + readonly isSelected: boolean; + readonly onClick: () => void; +} + +const TYPE_COLORS: Record = { + SHELF: '#d4a574', + BOOKCASE: '#b8860b', + WARDROBE: '#8b7355', + DRESSER: '#a0845c', + DESK: '#c9a96e', + TABLE: '#deb887', +}; + +/** Render a furniture item in wall elevation view. */ +export function ProjectionFurniture({ + projected, + wallHeight, + scale, + padding, + isSelected, + onClick, +}: ProjectionFurnitureProps) { + const { rect, item } = projected; + + const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding); + const pxWidth = rect.width * scale; + const pxHeight = rect.height * scale; + + const color = TYPE_COLORS[item.type] ?? '#a0845c'; + + return ( + + + {/* Furniture label */} + + + ); +} diff --git a/apps/client/src/components/editor/projection/ProjectionMeasurements.tsx b/apps/client/src/components/editor/projection/ProjectionMeasurements.tsx new file mode 100644 index 0000000..70db6c2 --- /dev/null +++ b/apps/client/src/components/editor/projection/ProjectionMeasurements.tsx @@ -0,0 +1,241 @@ +import type { ReactNode } from 'react'; +import { Group, Line, Text } from 'react-konva'; +import type { ProjectedOpening, ProjectedElectrical } from '../utils/projectionMapping'; +import { projectionToPixel } from '../utils/projectionMapping'; + +interface ProjectionMeasurementsProps { + readonly projectedOpenings: readonly ProjectedOpening[]; + readonly projectedElectrical: readonly ProjectedElectrical[]; + readonly wallLength: number; + readonly wallHeight: number; + readonly scale: number; + readonly padding: number; +} + +/** Dimension line with arrows and text. */ +function DimensionLine({ + x1, y1, x2, y2, label, offset, horizontal, +}: { + readonly x1: number; + readonly y1: number; + readonly x2: number; + readonly y2: number; + readonly label: string; + readonly offset: number; + readonly horizontal: boolean; +}) { + const arrowSize = 4; + + if (horizontal) { + const lineY = y1 + offset; + const midX = (x1 + x2) / 2; + return ( + + {/* Extension lines */} + + + {/* Main line */} + + {/* Arrows */} + + + {/* Label */} + + + ); + } + + // Vertical dimension + const lineX = x1 + offset; + const midY = (y1 + y2) / 2; + return ( + + + + + + + + + ); +} + +function formatM(meters: number): string { + return `${meters.toFixed(2)}m`; +} + +/** Render measurement annotations on a wall projection view. */ +export function ProjectionMeasurements({ + projectedOpenings, + projectedElectrical, + wallLength: wallLen, + wallHeight, + scale, + padding, +}: ProjectionMeasurementsProps) { + const elements: ReactNode[] = []; + + // Wall width dimension (along bottom) + const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding); + const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding); + elements.push( + , + ); + + // Wall height dimension (along right side) + const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding); + const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding); + elements.push( + , + ); + + // Opening dimensions: sill height for windows, door height for doors + for (const po of projectedOpenings) { + const { rect, opening } = po; + const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding); + const bottomLeft = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding); + + // Height annotation (vertical, left side of opening) + elements.push( + , + ); + + // Sill height for windows + if (opening.type === 'WINDOW' && rect.y > 0.01) { + const floorBelow = projectionToPixel(rect.x, 0, wallHeight, scale, padding); + elements.push( + , + ); + } + + // Width annotation (horizontal, above opening) + const topRight2 = projectionToPixel(rect.x + rect.width, rect.y + rect.height, wallHeight, scale, padding); + elements.push( + , + ); + } + + // Electrical item coordinate labels: (X; Y) near each item + for (const pe of projectedElectrical) { + const center = projectionToPixel( + pe.position.alongWall, + pe.position.fromFloor, + wallHeight, + scale, + padding, + ); + + const coordLabel = `(${pe.position.alongWall.toFixed(2)}; ${pe.elevation.toFixed(2)})`; + elements.push( + , + ); + } + + // Opening horizontal position labels + for (const po of projectedOpenings) { + const { rect, opening } = po; + const openingCenter = projectionToPixel( + rect.x + rect.width / 2, + rect.y + rect.height, + wallHeight, + scale, + padding, + ); + elements.push( + , + ); + } + + return {elements}; +} diff --git a/apps/client/src/components/editor/projection/ProjectionPanel.tsx b/apps/client/src/components/editor/projection/ProjectionPanel.tsx new file mode 100644 index 0000000..33ad058 --- /dev/null +++ b/apps/client/src/components/editor/projection/ProjectionPanel.tsx @@ -0,0 +1,299 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ElectricalItem, WallOpening } from '@house-plan-maker/shared'; +import { useEditor } from '../context/EditorContext'; +import { wallDirectionKey } from '../utils/projectionMapping'; +import { wallStartEnd } from '../utils/wallUtils'; +import { generateLocalId } from '../utils/geometry'; +import { getDefaultElevation } from '../tools/ElectricalTool'; +import { ELECTRICAL_SYMBOL_DEFS } from '../symbols/electrical'; +import { WallProjectionView } from './WallProjectionView'; +import styles from './projection-panel.module.css'; + +type LayoutMode = 'tabs' | 'grid'; + +interface ProjectionPanelProps { + readonly fullView?: boolean; + readonly onStageRef?: (wallId: string, stage: import('konva').default.Stage | null) => void; +} + +export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPanelProps = {}) { + const { t } = useTranslation(); + const { state, selectElement, updateElectrical, updateOpening, addElectrical } = useEditor(); + const { + walls, + openings, + electricalItems, + furnitureItems, + annotations, + room, + selectedIds, + activeTool, + selectedElectricalIndex, + layerVisibility, + } = state; + + const [isCollapsed, setIsCollapsed] = useState(false); + const [layoutMode, setLayoutMode] = useState('tabs'); + const [activeWallIndex, setActiveWallIndex] = useState(0); + const contentRef = useRef(null); + const [contentSize, setContentSize] = useState({ width: 600, height: 240 }); + + // ── Determine active electrical type for placement ── + const selectedElectricalType = useMemo(() => { + if (activeTool !== 'electrical' || selectedElectricalIndex == null) return null; + const def = ELECTRICAL_SYMBOL_DEFS[selectedElectricalIndex]; + return def?.type ?? null; + }, [activeTool, selectedElectricalIndex]); + + // ── Determine which wall is selected in 2D view ── + const highlightedWallId = useMemo(() => { + for (const wall of walls) { + if (selectedIds.has(wall.id)) return wall.id; + } + for (const opening of openings) { + if (selectedIds.has(opening.id)) return opening.wallId; + } + for (const elec of electricalItems) { + if (selectedIds.has(elec.id) && elec.wallId) return elec.wallId; + } + return null; + }, [walls, openings, electricalItems, selectedIds]); + + // ── Auto-switch tab when a wall-related item is selected ── + useEffect(() => { + if (highlightedWallId && layoutMode === 'tabs') { + const idx = walls.findIndex((w) => w.id === highlightedWallId); + if (idx >= 0) { + setActiveWallIndex(idx); + } + } + }, [highlightedWallId, walls, layoutMode]); + + // ── Resize observer ── + useEffect(() => { + const container = contentRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setContentSize({ width: Math.floor(width), height: Math.floor(height) }); + } + }); + + observer.observe(container); + const rect = container.getBoundingClientRect(); + setContentSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) }); + + return () => observer.disconnect(); + }, [isCollapsed, fullView]); + + const handleToggleCollapse = useCallback(() => { + if (fullView) return; + setIsCollapsed((prev) => !prev); + }, [fullView]); + + const handleToggleLayout = useCallback(() => { + setLayoutMode((prev) => (prev === 'tabs' ? 'grid' : 'tabs')); + }, []); + + const handleSelectElement = useCallback( + (id: string) => { + selectElement(id); + }, + [selectElement], + ); + + // ── Drag callbacks ── + const handleUpdateElectrical = useCallback( + (item: ElectricalItem) => { + updateElectrical(item); + }, + [updateElectrical], + ); + + const handleUpdateOpening = useCallback( + (opening: WallOpening) => { + updateOpening(opening); + }, + [updateOpening], + ); + + // ── Placement callback ── + const handlePlaceElectrical = useCallback( + (wallId: string, alongWall: number, fromFloor: number) => { + if (selectedElectricalIndex == null) return; + const symbolDef = ELECTRICAL_SYMBOL_DEFS[selectedElectricalIndex]; + if (!symbolDef) return; + + const wall = walls.find((w) => w.id === wallId); + if (!wall) return; + + // Convert projection coordinates (alongWall) back to room 2D coordinates + const { start, end } = wallStartEnd(wall); + const wallLen = Math.sqrt( + (end.x - start.x) ** 2 + (end.y - start.y) ** 2, + ); + if (wallLen === 0) return; + + const dx = (end.x - start.x) / wallLen; + const dy = (end.y - start.y) / wallLen; + const x = start.x + dx * alongWall; + const y = start.y + dy * alongWall; + + // Use the clicked elevation, or fall back to default for the type + const elevation = symbolDef.wallMounted + ? fromFloor + : getDefaultElevation(symbolDef.type, room.wallHeight); + + const metadata: Record | null = + symbolDef.variant ? { variant: symbolDef.variant } : null; + + const newItem: ElectricalItem = { + id: generateLocalId(), + roomId: room.id, + type: symbolDef.type, + x, + y, + wallId: symbolDef.wallMounted ? wallId : null, + elevationFromFloor: elevation, + rotation: (Math.atan2(dy, dx) * 180) / Math.PI, + metadata, + }; + + addElectrical(newItem); + selectElement(newItem.id); + }, + [selectedElectricalIndex, walls, room, addElectrical, selectElement], + ); + + if (walls.length === 0) { + return null; + } + + const clampedIndex = Math.min(activeWallIndex, walls.length - 1); + + const sharedProps = { + openings, + electricalItems: layerVisibility.electrical ? electricalItems : [], + furnitureItems: layerVisibility.furniture ? furnitureItems : [], + annotations: layerVisibility.annotations ? annotations : [], + wallHeight: room.wallHeight, + plinthHeight: room.plinthHeight, + selectedIds, + onSelectElement: handleSelectElement, + onStageRef, + onUpdateElectrical: handleUpdateElectrical, + onUpdateOpening: handleUpdateOpening, + onPlaceElectrical: handlePlaceElectrical, + showMeasurements: layerVisibility.measurements, + activeTool, + selectedElectricalType, + } as const; + + const panelClassName = fullView + ? `${styles.panel} ${styles.panelFullView}` + : `${styles.panel} ${isCollapsed ? styles.panelCollapsed : styles.panelExpanded}`; + + const isContentVisible = fullView || !isCollapsed; + + return ( +
+ {/* Header — hidden in full-view mode (no collapse needed) */} + {!fullView && ( +
+
+ + ▼ + + {t('projection.title')} +
+ {!isCollapsed && ( + + )} +
+ )} + + {/* Layout toggle in full-view mode (inline bar instead of collapsible header) */} + {fullView && ( +
+ +
+ )} + + {isContentVisible && ( + <> + {/* Tabs (only in tab mode) */} + {layoutMode === 'tabs' && ( +
+ {walls.map((wall, idx) => ( + + ))} +
+ )} + + {/* Content area */} + {layoutMode === 'tabs' ? ( +
+ {walls[clampedIndex] ? ( +
+ +
+ ) : ( +
{t('projection.noWall')}
+ )} +
+ ) : ( +
+ {walls.slice(0, 4).map((wall) => ( +
+ +
+ ))} + {walls.length < 4 && + Array.from({ length: 4 - walls.length }).map((_, i) => ( +
+
--
+
+ ))} +
+ )} + + )} +
+ ); +} diff --git a/apps/client/src/components/editor/projection/ProjectionWindow.tsx b/apps/client/src/components/editor/projection/ProjectionWindow.tsx new file mode 100644 index 0000000..90f5a15 --- /dev/null +++ b/apps/client/src/components/editor/projection/ProjectionWindow.tsx @@ -0,0 +1,130 @@ +import { Group, Rect, Line } from 'react-konva'; +import type { ProjectedOpening } from '../utils/projectionMapping'; +import { projectionToPixel } from '../utils/projectionMapping'; + +interface ProjectionWindowProps { + readonly projected: ProjectedOpening; + readonly wallHeight: number; + readonly scale: number; + readonly padding: number; + readonly isSelected: boolean; + readonly isDragging?: boolean; + readonly dragAlongWall?: number; + readonly onClick: () => void; + readonly onDragStart?: (openingId: string, evt: MouseEvent) => void; +} + +/** Render a window in wall elevation view. */ +export function ProjectionWindow({ + projected, + wallHeight, + scale, + padding, + isSelected, + isDragging = false, + dragAlongWall, + onClick, + onDragStart, +}: ProjectionWindowProps) { + const { rect, opening } = projected; + + const displayX = isDragging && dragAlongWall != null + ? dragAlongWall - opening.width / 2 + : rect.x; + + const topLeft = projectionToPixel(displayX, rect.y + rect.height, wallHeight, scale, padding); + const pxWidth = rect.width * scale; + const pxHeight = rect.height * scale; + + const frameInset = 3; + + return ( + { + if (onDragStart && e.evt.button === 0) { + onDragStart(opening.id, e.evt); + } + }} + > + {/* Drag ghost outline */} + {isDragging && ( + + )} + {/* Window frame (outer) */} + + {/* Glass pane (inner rectangle) */} + + {/* Horizontal mullion (center divider) */} + + {/* Vertical mullion (center divider) */} + + {/* Glass cross lines for indication */} + + + + ); +} diff --git a/apps/client/src/components/editor/projection/WallProjectionView.tsx b/apps/client/src/components/editor/projection/WallProjectionView.tsx new file mode 100644 index 0000000..8b8dea9 --- /dev/null +++ b/apps/client/src/components/editor/projection/WallProjectionView.tsx @@ -0,0 +1,717 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Stage, Layer, Rect, Line, Text, Group } from 'react-konva'; +import type Konva from 'konva'; +import type { Wall, WallOpening, ElectricalItem, FurnitureItem, ElectricalType, Annotation } from '@house-plan-maker/shared'; +import { wallLength as computeWallLength, wallStartEnd } from '../utils/wallUtils'; +import { + projectionScale, + projectionToPixel, + pixelToProjection, + projectOpenings, + projectElectricalItems, + projectFurnitureItems, + computePlinthSegments, + wallDirectionLabel, +} from '../utils/projectionMapping'; +import { ProjectionDoor } from './ProjectionDoor'; +import { ProjectionWindow } from './ProjectionWindow'; +import { ProjectionElectrical } from './ProjectionElectrical'; +import { ProjectionFurniture } from './ProjectionFurniture'; +import { ProjectionMeasurements } from './ProjectionMeasurements'; +import type { EditorToolType } from '../types'; + +interface WallProjectionViewProps { + readonly wall: Wall; + readonly openings: readonly WallOpening[]; + readonly electricalItems: readonly ElectricalItem[]; + readonly furnitureItems: readonly FurnitureItem[]; + readonly annotations: readonly Annotation[]; + readonly showMeasurements?: boolean; + readonly wallHeight: number; + readonly plinthHeight: number; + readonly selectedIds: ReadonlySet; + readonly isHighlighted: boolean; + readonly onSelectElement: (id: string) => void; + readonly width: number; + readonly height: number; + readonly onStageRef?: (wallId: string, stage: Konva.Stage | null) => void; + readonly onUpdateElectrical?: (item: ElectricalItem) => void; + readonly onUpdateOpening?: (opening: WallOpening) => void; + readonly onPlaceElectrical?: (wallId: string, alongWall: number, fromFloor: number) => void; + readonly activeTool?: EditorToolType; + readonly selectedElectricalType?: ElectricalType | null; +} + +const PADDING = 40; +const PLINTH_COLOR = '#8b7355'; +const WALL_FILL = '#f8fafc'; +const WALL_STROKE = '#334155'; +const FLOOR_COLOR = '#94a3b8'; +const CEILING_COLOR = '#94a3b8'; + +/** Minimum pixel distance to distinguish a drag from a click. */ +const DRAG_THRESHOLD = 4; + +type DragKind = 'electrical-elevation' | 'opening-position'; + +interface DragInfo { + readonly kind: DragKind; + readonly itemId: string; + readonly startPixelX: number; + readonly startPixelY: number; + readonly exceeded: boolean; +} + +export function WallProjectionView({ + wall, + openings, + electricalItems, + furnitureItems, + annotations, + showMeasurements = true, + wallHeight, + plinthHeight, + selectedIds, + isHighlighted, + onSelectElement, + width, + height, + onStageRef, + onUpdateElectrical, + onUpdateOpening, + onPlaceElectrical, + activeTool, + selectedElectricalType, +}: WallProjectionViewProps) { + const { t } = useTranslation(); + const stageRef = useRef(null); + + // Expose stage ref to parent for export + useEffect(() => { + onStageRef?.(wall.id, stageRef.current); + }); + + // ── Zoom/Pan state ── + const [viewZoom, setViewZoom] = useState(1); + const [viewPan, setViewPan] = useState({ x: 0, y: 0 }); + const isPanningRef = useRef(false); + const panStartRef = useRef({ x: 0, y: 0 }); + const panStartOffsetRef = useRef({ x: 0, y: 0 }); + const viewPanRef = useRef(viewPan); + viewPanRef.current = viewPan; + + const wallLen = computeWallLength(wall); + const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING); + const effectiveScale = baseScale * viewZoom; + + // ── Drag state (refs for transient data, state only for visual feedback) ── + const dragRef = useRef(null); + const [dragElectricalFromFloor, setDragElectricalFromFloor] = useState<{ itemId: string; fromFloor: number } | null>(null); + const [dragElectricalAlongWall, setDragElectricalAlongWall] = useState<{ itemId: string; alongWall: number } | null>(null); + const [dragOpeningAlongWall, setDragOpeningAlongWall] = useState<{ openingId: string; alongWall: number } | null>(null); + + // ── Projected data (memoized) ── + const projectedOpenings = useMemo( + () => projectOpenings(wall, openings), + [wall, openings], + ); + const projectedElectrical = useMemo( + () => projectElectricalItems(wall, electricalItems), + [wall, electricalItems], + ); + const projectedFurniture = useMemo( + () => projectFurnitureItems(wall, furnitureItems), + [wall, furnitureItems], + ); + const plinthSegments = useMemo( + () => computePlinthSegments(wall, openings, plinthHeight), + [wall, openings, plinthHeight], + ); + + // ── Helper to get pixel position relative to stage ── + const getStagePointer = useCallback((evt: MouseEvent): { x: number; y: number } | null => { + const stage = stageRef.current; + if (!stage) return null; + const container = stage.container(); + const rect = container.getBoundingClientRect(); + return { + x: evt.clientX - rect.left, + y: evt.clientY - rect.top, + }; + }, []); + + // ── Electrical drag start ── + const handleElectricalDragStart = useCallback((itemId: string, evt: MouseEvent) => { + if (!onUpdateElectrical) return; + dragRef.current = { + kind: 'electrical-elevation', + itemId, + startPixelX: evt.clientX, + startPixelY: evt.clientY, + exceeded: false, + }; + }, [onUpdateElectrical]); + + // ── Opening drag start ── + const handleOpeningDragStart = useCallback((openingId: string, evt: MouseEvent) => { + if (!onUpdateOpening) return; + dragRef.current = { + kind: 'opening-position', + itemId: openingId, + startPixelX: evt.clientX, + startPixelY: evt.clientY, + exceeded: false, + }; + }, [onUpdateOpening]); + + // ── Zoom handler ── + const handleWheel = useCallback((e: Konva.KonvaEventObject) => { + e.evt.preventDefault(); + const stage = e.target.getStage(); + if (!stage) return; + + const pointerPos = stage.getPointerPosition(); + if (!pointerPos) return; + + // Content is inside a Group with x=viewPan.x, y=viewPan.y. + // projectionToPixel gives: contentX = PADDING + worldX * effectiveScale + // On screen: screenX = contentX + viewPan.x + // So: worldX = (screenX - viewPan.x - PADDING) / effectiveScale + + const oldScale = effectiveScale; + const worldX = (pointerPos.x - viewPan.x - PADDING) / oldScale; + const worldY = (pointerPos.y - viewPan.y - PADDING) / oldScale; + + const delta = e.evt.deltaY; + const factor = delta > 0 ? 0.9 : 1.1; + const newZoom = Math.max(0.3, Math.min(5, viewZoom * factor)); + const newScale = baseScale * newZoom; + + // Keep world point under cursor: screenX = PADDING + worldX * newScale + newPanX + const newPan = { + x: pointerPos.x - PADDING - worldX * newScale, + y: pointerPos.y - PADDING - worldY * newScale, + }; + + setViewZoom(newZoom); + setViewPan(newPan); + }, [effectiveScale, viewPan, viewZoom, baseScale]); + + // ── Pan handlers ── + const handleMouseDown = useCallback((e: Konva.KonvaEventObject) => { + // Middle mouse or left mouse on empty space = pan + const inPlacementMode = activeTool === 'electrical' && selectedElectricalType != null; + if (e.evt.button === 1 || (e.evt.button === 0 && !inPlacementMode)) { + // Don't pan if clicking on an interactive element (electrical/opening/furniture) + const targetName = (e.target as { name?: () => string })?.name?.() ?? ''; + const isOnItem = e.target !== e.target.getStage() && targetName !== 'wall-bg'; + if (e.evt.button === 0 && isOnItem) { + // Let the click fall through to item handlers + } else { + isPanningRef.current = true; + panStartRef.current = { x: e.evt.clientX, y: e.evt.clientY }; + panStartOffsetRef.current = { ...viewPan }; + e.evt.preventDefault(); + return; + } + } + + // Left click on wall-bg in placement mode: place electrical + if (e.evt.button === 0 && e.target === e.currentTarget?.getStage()?.findOne('.wall-bg')) { + // This is handled in the wall-bg rect click + } + }, [viewPan]); + + const handleMouseMove = useCallback((e: Konva.KonvaEventObject) => { + // Handle panning + if (isPanningRef.current) { + const dx = e.evt.clientX - panStartRef.current.x; + const dy = e.evt.clientY - panStartRef.current.y; + setViewPan({ + x: panStartOffsetRef.current.x + dx, + y: panStartOffsetRef.current.y + dy, + }); + return; + } + + // Handle dragging + const drag = dragRef.current; + if (!drag) return; + + const dx = e.evt.clientX - drag.startPixelX; + const dy = e.evt.clientY - drag.startPixelY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (!drag.exceeded && dist < DRAG_THRESHOLD) return; + + if (!drag.exceeded) { + dragRef.current = { ...drag, exceeded: true }; + } + + const pointer = getStagePointer(e.evt); + if (!pointer) return; + + const proj = pixelToProjection(pointer.x - viewPanRef.current.x, pointer.y - viewPanRef.current.y, wallHeight, effectiveScale, PADDING); + + if (drag.kind === 'electrical-elevation') { + if (e.evt.ctrlKey || e.evt.metaKey) { + // Ctrl+drag: move horizontally along wall + const clampedAlongWall = Math.max(0, Math.min(wallLen, proj.alongWall)); + setDragElectricalAlongWall({ itemId: drag.itemId, alongWall: clampedAlongWall }); + setDragElectricalFromFloor(null); + } else { + // Normal drag: move vertically (elevation) + const clampedFromFloor = Math.max(0, Math.min(wallHeight, proj.fromFloor)); + setDragElectricalFromFloor({ itemId: drag.itemId, fromFloor: clampedFromFloor }); + setDragElectricalAlongWall(null); + } + } else if (drag.kind === 'opening-position') { + const opening = openings.find((o) => o.id === drag.itemId); + const halfWidth = opening ? opening.width / 2 : 0; + const clampedAlongWall = Math.max(halfWidth, Math.min(wallLen - halfWidth, proj.alongWall)); + setDragOpeningAlongWall({ openingId: drag.itemId, alongWall: clampedAlongWall }); + } + }, [getStagePointer, wallHeight, effectiveScale, wallLen, openings]); + + const handleMouseUp = useCallback(() => { + isPanningRef.current = false; + + const drag = dragRef.current; + if (!drag) return; + + dragRef.current = null; + + // If threshold was not exceeded, this was a click, not a drag -- do nothing extra + if (!drag.exceeded) { + setDragElectricalFromFloor(null); + setDragElectricalAlongWall(null); + setDragOpeningAlongWall(null); + return; + } + + // Commit the drag + if (drag.kind === 'electrical-elevation' && onUpdateElectrical) { + const item = electricalItems.find((i) => i.id === drag.itemId); + if (item) { + if (dragElectricalAlongWall) { + // Horizontal drag: compute new x,y from alongWall position on the wall + const { start, end } = wallStartEnd(wall); + const wLen = wallLen || 1; + const t = dragElectricalAlongWall.alongWall / wLen; + onUpdateElectrical({ + ...item, + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + }); + } else if (dragElectricalFromFloor) { + onUpdateElectrical({ + ...item, + elevationFromFloor: Math.round(dragElectricalFromFloor.fromFloor * 100) / 100, + }); + } + } + } else if (drag.kind === 'opening-position' && dragOpeningAlongWall && onUpdateOpening) { + const opening = openings.find((o) => o.id === drag.itemId); + if (opening) { + onUpdateOpening({ + ...opening, + positionAlongWall: Math.round(dragOpeningAlongWall.alongWall * 100) / 100, + }); + } + } + + setDragElectricalFromFloor(null); + setDragElectricalAlongWall(null); + setDragOpeningAlongWall(null); + }, [dragElectricalFromFloor, dragElectricalAlongWall, dragOpeningAlongWall, electricalItems, openings, wall, wallLen, onUpdateElectrical, onUpdateOpening]); + + // ── Handle click on wall background for placement ── + const handleWallBgClick = useCallback((e: Konva.KonvaEventObject) => { + // Only place when electrical tool is active + if (activeTool !== 'electrical' || !selectedElectricalType || !onPlaceElectrical) return; + + const pointer = getStagePointer(e.evt); + if (!pointer) return; + + const proj = pixelToProjection(pointer.x - viewPanRef.current.x, pointer.y - viewPanRef.current.y, wallHeight, effectiveScale, PADDING); + + // Only place within wall bounds + if (proj.alongWall < 0 || proj.alongWall > wallLen) return; + if (proj.fromFloor < 0 || proj.fromFloor > wallHeight) return; + + onPlaceElectrical(wall.id, proj.alongWall, proj.fromFloor); + }, [activeTool, selectedElectricalType, onPlaceElectrical, getStagePointer, wallHeight, effectiveScale, wallLen, wall.id]); + + // ── Reset zoom when wall changes ── + useEffect(() => { + setViewZoom(1); + setViewPan({ x: 0, y: 0 }); + }, [wall.id]); + + // ── Coordinate helpers ── + const toPixel = useCallback( + (alongWall: number, fromFloor: number) => + projectionToPixel(alongWall, fromFloor, wallHeight, effectiveScale, PADDING), + [wallHeight, effectiveScale], + ); + + // Wall rectangle corners + const topLeft = toPixel(0, wallHeight); + const topRight = toPixel(wallLen, wallHeight); + const bottomLeft = toPixel(0, 0); + const bottomRight = toPixel(wallLen, 0); + + const label = wallDirectionLabel(wall); + const isPlacementMode = activeTool === 'electrical' && selectedElectricalType != null; + + return ( + + + {/* Background */} + + + {/* Pan group: all content shifts with viewPan */} + + + {/* Grid lines */} + {(() => { + const gridSize = 0.1; // 10cm grid + const gridPixels = gridSize * effectiveScale; + if (gridPixels < 6) return null; // too dense, skip + + const lines: React.ReactNode[] = []; + const majorEvery = 5; // every 0.5m = bold line + + // Vertical grid lines (along wall) + const startAlongWall = Math.floor(0 / gridSize) * gridSize; + const endAlongWall = Math.ceil(wallLen / gridSize) * gridSize; + for (let w = startAlongWall; w <= endAlongWall; w += gridSize) { + const px = PADDING + w * effectiveScale; + const isMajor = Math.abs(Math.round(w / gridSize) % majorEvery) < 0.01; + lines.push( + , + ); + } + + // Horizontal grid lines (height from floor) + const startHeight = 0; + const endHeight = Math.ceil(wallHeight / gridSize) * gridSize; + for (let h = startHeight; h <= endHeight; h += gridSize) { + const py = PADDING + (wallHeight - h) * effectiveScale; + const isMajor = Math.abs(Math.round(h / gridSize) % majorEvery) < 0.01; + lines.push( + , + ); + } + + return <>{lines}; + })()} + + {/* Ruler ticks along bottom (wall length) */} + {(() => { + const step = 0.5; // every 0.5m + const ticks: React.ReactNode[] = []; + for (let w = 0; w <= wallLen; w += step) { + const px = PADDING + w * effectiveScale; + const py = PADDING + wallHeight * effectiveScale; + ticks.push( + , + ); + ticks.push( + , + ); + } + return <>{ticks}; + })()} + + {/* Ruler ticks along left (height from floor) */} + {(() => { + const step = 0.5; + const ticks: React.ReactNode[] = []; + for (let h = 0; h <= wallHeight; h += step) { + const py = PADDING + (wallHeight - h) * effectiveScale; + ticks.push( + , + ); + ticks.push( + , + ); + } + return <>{ticks}; + })()} + + {/* Wall rectangle fill (clickable for placement) */} + + + {/* Ceiling line */} + + + {/* Floor line */} + + + {/* Plinth segments */} + {plinthSegments.map((seg, i) => { + const segTopLeft = toPixel(seg.x, seg.height); + const pxWidth = seg.width * effectiveScale; + const pxHeight = seg.height * effectiveScale; + return ( + + ); + })} + + {/* Doors */} + {projectedOpenings + .filter((po) => po.opening.type === 'DOOR') + .map((po) => { + const isDragging = dragOpeningAlongWall?.openingId === po.opening.id; + return ( + onSelectElement(po.opening.id)} + onDragStart={onUpdateOpening ? handleOpeningDragStart : undefined} + /> + ); + })} + + {/* Windows */} + {projectedOpenings + .filter((po) => po.opening.type === 'WINDOW') + .map((po) => { + const isDragging = dragOpeningAlongWall?.openingId === po.opening.id; + return ( + onSelectElement(po.opening.id)} + onDragStart={onUpdateOpening ? handleOpeningDragStart : undefined} + /> + ); + })} + + {/* Furniture items (rendered first so electrical overlays them) */} + {projectedFurniture.map((pf) => ( + onSelectElement(pf.item.id)} + /> + ))} + + {/* Electrical items (on top of furniture) */} + {projectedElectrical.map((pe) => { + const isDraggingV = dragElectricalFromFloor?.itemId === pe.item.id; + const isDraggingH = dragElectricalAlongWall?.itemId === pe.item.id; + return ( + onSelectElement(pe.item.id)} + onDragStart={onUpdateElectrical ? handleElectricalDragStart : undefined} + /> + ); + })} + + {/* Measurements */} + {showMeasurements && ( + + )} + + {/* Attached annotations for items on this wall */} + {annotations + .filter((ann) => { + if (!ann.attachedToId) return false; + return projectedElectrical.some((pe) => pe.item.id === ann.attachedToId) || + projectedFurniture.some((pf) => pf.item.id === ann.attachedToId); + }) + .map((ann) => { + // Find parent item position in projection coords + const elec = projectedElectrical.find((pe) => pe.item.id === ann.attachedToId); + const furn = projectedFurniture.find((pf) => pf.item.id === ann.attachedToId); + let anchorAlongWall = 0; + let anchorFromFloor = 0; + if (elec) { + anchorAlongWall = elec.position.alongWall; + anchorFromFloor = elec.position.fromFloor; + } else if (furn) { + anchorAlongWall = furn.rect.x + furn.rect.width / 2; + anchorFromFloor = furn.rect.y + furn.rect.height; + } + const anchorPx = projectionToPixel(anchorAlongWall, anchorFromFloor, wallHeight, effectiveScale, PADDING); + // Offset annotation slightly + const textX = anchorPx.x + ann.x * effectiveScale; + const textY = anchorPx.y + ann.y * effectiveScale; + return ( + + + + + + ); + }) + } + + {/* Wall label (inside pan group) */} + + + + + {/* Placement mode indicator (fixed on screen, outside pan group) */} + {isPlacementMode && ( + + )} + + + ); +} diff --git a/apps/client/src/components/editor/projection/projection-panel.module.css b/apps/client/src/components/editor/projection/projection-panel.module.css new file mode 100644 index 0000000..4304d71 --- /dev/null +++ b/apps/client/src/components/editor/projection/projection-panel.module.css @@ -0,0 +1,159 @@ +.panel { + border-top: 1px solid var(--color-border); + background-color: var(--color-bg-elevated); + display: flex; + flex-direction: column; + overflow: hidden; + transition: height var(--transition-normal); +} + +.panelCollapsed { + height: 36px; +} + +.panelExpanded { + height: 320px; +} + +.panelFullView { + flex: 1; + height: 100%; + min-height: 0; + border-top: none; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-1) var(--space-3); + border-bottom: 1px solid var(--color-border); + cursor: pointer; + user-select: none; + flex-shrink: 0; +} + +.header:hover { + background-color: var(--color-bg-hover); +} + +.headerLeft { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.headerTitle { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + letter-spacing: var(--letter-spacing-wide); + text-transform: uppercase; +} + +.collapseIcon { + font-size: 12px; + color: var(--color-text-muted); + transition: transform var(--transition-fast); +} + +.collapseIconOpen { + transform: rotate(180deg); +} + +.tabs { + display: flex; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; + overflow-x: auto; +} + +.tab { + padding: var(--space-1) var(--space-3); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-muted); + border: none; + border-bottom: 2px solid transparent; + background: none; + cursor: pointer; + white-space: nowrap; + font-family: var(--font-family); + transition: all var(--transition-fast); +} + +.tab:hover { + color: var(--color-text-primary); + background-color: var(--color-bg-hover); +} + +.tabActive { + color: var(--color-accent-700); + border-bottom-color: var(--color-accent-500); +} + +.content { + flex: 1; + overflow: hidden; + display: flex; + min-height: 0; +} + +.viewContainer { + flex: 1; + overflow: hidden; + position: relative; + min-height: 0; + width: 100%; + height: 100%; +} + +.gridLayout { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 1px; + background-color: var(--color-border); + flex: 1; + overflow: hidden; +} + +.gridCell { + background-color: var(--color-bg); + overflow: hidden; + position: relative; +} + +.emptyState { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.fullViewToolbar { + display: flex; + align-items: center; + justify-content: flex-end; + padding: var(--space-1) var(--space-3); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.layoutToggle { + padding: var(--space-1); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: none; + cursor: pointer; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + font-family: var(--font-family); +} + +.layoutToggle:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} diff --git a/apps/client/src/components/editor/properties-panel.module.css b/apps/client/src/components/editor/properties-panel.module.css new file mode 100644 index 0000000..692d2a9 --- /dev/null +++ b/apps/client/src/components/editor/properties-panel.module.css @@ -0,0 +1,133 @@ +.panel { + width: 260px; + min-width: 260px; + background-color: var(--color-bg-elevated); + border-left: 1px solid var(--color-border); + overflow-y: auto; + flex-shrink: 0; +} + +.header { + padding: var(--space-3) var(--space-4); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border); + letter-spacing: var(--letter-spacing-wide); + text-transform: uppercase; +} + +.empty { + padding: var(--space-6) var(--space-4); + text-align: center; +} + +.emptyText { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin-bottom: var(--space-1); +} + +.emptyHint { + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.section { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.sectionTitle { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + margin-bottom: var(--space-2); +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-1) 0; + min-height: 28px; +} + +.rowLabel { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + flex-shrink: 0; +} + +.rowValue { + font-size: var(--font-size-xs); + color: var(--color-text-primary); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.editableValue { + font-size: var(--font-size-xs); + color: var(--color-text-primary); + font-variant-numeric: tabular-nums; + text-align: right; + background: none; + border: none; + padding: var(--space-1) var(--space-1); + border-radius: var(--radius-sm); + cursor: pointer; + font-family: var(--font-family); + transition: background var(--transition-fast); +} + +.editableValue:hover { + background-color: var(--color-bg-hover); +} + +.editRow { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.editInput { + width: 60px; + padding: 2px var(--space-1); + border: 1px solid var(--color-accent-300); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-family: var(--font-mono); + color: var(--color-text-primary); + text-align: right; + outline: none; + background-color: var(--color-bg-elevated); +} + +.editInput:focus { + border-color: var(--color-accent-500); + box-shadow: 0 0 0 2px var(--color-focus-ring); +} + +.editUnit { + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.selectInput { + padding: 2px var(--space-1); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-family: var(--font-family); + color: var(--color-text-primary); + background-color: var(--color-bg-elevated); + cursor: pointer; + outline: none; +} + +.selectInput:focus { + border-color: var(--color-accent-500); + box-shadow: 0 0 0 2px var(--color-focus-ring); +} diff --git a/apps/client/src/components/editor/room-editor-layout.module.css b/apps/client/src/components/editor/room-editor-layout.module.css new file mode 100644 index 0000000..1b6be07 --- /dev/null +++ b/apps/client/src/components/editor/room-editor-layout.module.css @@ -0,0 +1,102 @@ +/* + * The editor layout is rendered inside the AppShell's content area. + * We use a negative margin + full viewport calc to break out of the + * content padding/max-width constraints for a full-bleed editor experience. + */ +.layout { + display: flex; + flex-direction: column; + /* Fill remaining height below the AppShell header */ + height: calc(100vh - var(--header-height)); + /* Break out of content padding */ + margin: calc(-1 * var(--space-6)); + overflow: hidden; + background-color: var(--color-bg); +} + +@media (min-width: 768px) { + .layout { + margin: calc(-1 * var(--space-8)); + } +} + +.main { + display: flex; + flex: 1; + overflow: hidden; + min-height: 0; + height: 100%; +} + +.canvasArea { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; + position: relative; + min-height: 0; +} + +.canvasContainer { + flex: 1; + overflow: hidden; + position: relative; + min-height: 0; + width: 100%; +} + +.saveError { + padding: var(--space-2) var(--space-4); + background-color: var(--color-danger-50); + border-bottom: 1px solid var(--color-danger-100); + color: var(--color-danger-700); + font-size: var(--font-size-sm); + text-align: center; +} + +/* ── 3D / 2D View Toggle ── */ + +.viewToggle { + display: flex; + gap: 2px; + padding: 4px 8px; + background: var(--color-bg); + border-bottom: 1px solid var(--color-border); +} + +.viewToggleBtn { + padding: 4px 14px; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-normal); +} + +.viewToggleBtn:hover { + background: var(--color-bg-hover); +} + +.viewToggleBtnActive { + background: var(--color-accent-600); + color: var(--color-text-on-accent); + border-color: var(--color-accent-700); +} + +.viewToggleBtnActive:hover { + background: var(--color-accent-700); +} + +.loading3D { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--color-text-muted); + font-size: var(--font-size-base); +} diff --git a/apps/client/src/components/editor/symbols/electrical/CableRouteSymbol.tsx b/apps/client/src/components/editor/symbols/electrical/CableRouteSymbol.tsx new file mode 100644 index 0000000..c8387c5 --- /dev/null +++ b/apps/client/src/components/editor/symbols/electrical/CableRouteSymbol.tsx @@ -0,0 +1,23 @@ +import { Line } from 'react-konva'; + +interface CableRouteSymbolProps { + /** Flat array of [x1,y1, x2,y2, ...] in screen coordinates. */ + readonly points: readonly number[]; + readonly color: string; + readonly isSelected: boolean; +} + +/** IEC cable route: dashed line connecting elements. */ +export function CableRouteSymbol({ points, color, isSelected }: CableRouteSymbolProps) { + return ( + + ); +} diff --git a/apps/client/src/components/editor/symbols/electrical/CeilingLightSymbol.tsx b/apps/client/src/components/editor/symbols/electrical/CeilingLightSymbol.tsx new file mode 100644 index 0000000..56a566d --- /dev/null +++ b/apps/client/src/components/editor/symbols/electrical/CeilingLightSymbol.tsx @@ -0,0 +1,23 @@ +import { Group, Circle, Line } from 'react-konva'; + +interface CeilingLightSymbolProps { + readonly x: number; + readonly y: number; + readonly rotation: number; + readonly color: string; + readonly scale: number; +} + +/** IEC ceiling light: circle with cross pattern inside. */ +export function CeilingLightSymbol({ x, y, rotation, color, scale }: CeilingLightSymbolProps) { + const r = 10 * scale; + + return ( + + + {/* Cross pattern */} + + + + ); +} diff --git a/apps/client/src/components/editor/symbols/electrical/JunctionBoxSymbol.tsx b/apps/client/src/components/editor/symbols/electrical/JunctionBoxSymbol.tsx new file mode 100644 index 0000000..10e3810 --- /dev/null +++ b/apps/client/src/components/editor/symbols/electrical/JunctionBoxSymbol.tsx @@ -0,0 +1,33 @@ +import { Group, Rect, Line } from 'react-konva'; + +interface JunctionBoxSymbolProps { + readonly x: number; + readonly y: number; + readonly rotation: number; + readonly color: string; + readonly scale: number; +} + +/** IEC junction box: square with an X inside. */ +export function JunctionBoxSymbol({ x, y, rotation, color, scale }: JunctionBoxSymbolProps) { + const size = 14 * scale; + const half = size / 2; + + return ( + + + {/* X cross */} + + + + ); +} diff --git a/apps/client/src/components/editor/symbols/electrical/OutletSymbol.tsx b/apps/client/src/components/editor/symbols/electrical/OutletSymbol.tsx new file mode 100644 index 0000000..338d84b --- /dev/null +++ b/apps/client/src/components/editor/symbols/electrical/OutletSymbol.tsx @@ -0,0 +1,77 @@ +import { Group, Circle, Line } from 'react-konva'; + +interface OutletSymbolProps { + readonly x: number; + readonly y: number; + readonly rotation: number; + readonly color: string; + readonly scale: number; +} + +/** + * IEC 60617 outlet symbol variants. + * Base: circle with two parallel prongs. + */ + +/** Single outlet: circle + two vertical prongs. */ +export function SingleOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) { + const r = 8 * scale; + const prongLen = 4 * scale; + const prongGap = 3 * scale; + + return ( + + + + + + ); +} + +/** Double outlet: two overlapping circles + prongs. */ +export function DoubleOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) { + const r = 8 * scale; + const offset = 6 * scale; + const prongLen = 3 * scale; + const prongGap = 2.5 * scale; + + return ( + + {/* Left outlet */} + + + + + + {/* Right outlet */} + + + + + + + ); +} + +/** Grounded outlet: circle + two prongs + earth symbol (horizontal line + ground lines below). */ +export function GroundedOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) { + const r = 8 * scale; + const prongLen = 3 * scale; + const prongGap = 3 * scale; + const earthY = 5 * scale; + const earthW = 4 * scale; + + return ( + + + {/* Prongs */} + + + {/* Earth symbol — vertical line down + three horizontal lines */} + + + + + + ); +} diff --git a/apps/client/src/components/editor/symbols/electrical/SwitchSymbol.tsx b/apps/client/src/components/editor/symbols/electrical/SwitchSymbol.tsx new file mode 100644 index 0000000..c606783 --- /dev/null +++ b/apps/client/src/components/editor/symbols/electrical/SwitchSymbol.tsx @@ -0,0 +1,105 @@ +import { Group, Circle, Line, Arc } from 'react-konva'; + +interface SwitchSymbolProps { + readonly x: number; + readonly y: number; + readonly rotation: number; + readonly color: string; + readonly scale: number; +} + +/** + * IEC 60617 switch symbols. + * Base: circle with a line at angle (toggle arm). + */ + +/** Single switch: circle with angled line. */ +export function SingleSwitchSymbol({ x, y, rotation, color, scale }: SwitchSymbolProps) { + const r = 6 * scale; + const armLen = 14 * scale; + + return ( + + + {/* Toggle arm at 45 degrees from bottom of circle */} + + + ); +} + +/** Double switch: circle with two toggle marks. */ +export function DoubleSwitchSymbol({ x, y, rotation, color, scale }: SwitchSymbolProps) { + const r = 6 * scale; + const armLen = 14 * scale; + const tickLen = 3 * scale; + + return ( + + + {/* Toggle arm */} + + {/* Two crossing ticks on the arm to indicate double */} + + + + ); +} + +/** Dimmer switch: circle with an arc symbol. */ +export function DimmerSwitchSymbol({ x, y, rotation, color, scale }: SwitchSymbolProps) { + const r = 6 * scale; + const armLen = 14 * scale; + + return ( + + + {/* Toggle arm */} + + {/* Dimmer arc inside circle */} + + + ); +} diff --git a/apps/client/src/components/editor/symbols/electrical/WallLightSymbol.tsx b/apps/client/src/components/editor/symbols/electrical/WallLightSymbol.tsx new file mode 100644 index 0000000..3dc65ba --- /dev/null +++ b/apps/client/src/components/editor/symbols/electrical/WallLightSymbol.tsx @@ -0,0 +1,32 @@ +import { Group, Arc, Line } from 'react-konva'; + +interface WallLightSymbolProps { + readonly x: number; + readonly y: number; + readonly rotation: number; + readonly color: string; + readonly scale: number; +} + +/** IEC wall light: half-circle against wall (flat side toward wall). */ +export function WallLightSymbol({ x, y, rotation, color, scale }: WallLightSymbolProps) { + const r = 10 * scale; + + return ( + + {/* Half circle — the flat side faces the wall */} + + {/* Flat baseline along wall */} + + + ); +} diff --git a/apps/client/src/components/editor/symbols/electrical/index.ts b/apps/client/src/components/editor/symbols/electrical/index.ts new file mode 100644 index 0000000..dd46bb3 --- /dev/null +++ b/apps/client/src/components/editor/symbols/electrical/index.ts @@ -0,0 +1,42 @@ +export { SingleOutletSymbol, DoubleOutletSymbol, GroundedOutletSymbol } from './OutletSymbol'; +export { SingleSwitchSymbol, DoubleSwitchSymbol, DimmerSwitchSymbol } from './SwitchSymbol'; +export { JunctionBoxSymbol } from './JunctionBoxSymbol'; +export { CeilingLightSymbol } from './CeilingLightSymbol'; +export { WallLightSymbol } from './WallLightSymbol'; +export { CableRouteSymbol } from './CableRouteSymbol'; + +import type { ElectricalType } from '@house-plan-maker/shared'; + +/** Metadata for each electrical symbol type. */ +export interface ElectricalSymbolDef { + readonly type: ElectricalType; + readonly label: string; + readonly category: 'outlet' | 'switch' | 'junction' | 'light' | 'cable'; + /** Whether this item should snap to a wall. */ + readonly wallMounted: boolean; + /** Default metadata variant (e.g. 'single', 'double', 'grounded'). */ + readonly variant?: string; + /** Light coverage radius in meters (for lights). */ + readonly coverageRadius?: number; +} + +export const ELECTRICAL_SYMBOL_DEFS: readonly ElectricalSymbolDef[] = [ + { type: 'OUTLET', label: 'Single Outlet', category: 'outlet', wallMounted: true, variant: 'single' }, + { type: 'OUTLET', label: 'Double Outlet', category: 'outlet', wallMounted: true, variant: 'double' }, + { type: 'OUTLET', label: 'Grounded Outlet', category: 'outlet', wallMounted: true, variant: 'grounded' }, + { type: 'SWITCH', label: 'Single Switch', category: 'switch', wallMounted: true, variant: 'single' }, + { type: 'SWITCH', label: 'Double Switch', category: 'switch', wallMounted: true, variant: 'double' }, + { type: 'SWITCH', label: 'Dimmer Switch', category: 'switch', wallMounted: true, variant: 'dimmer' }, + { type: 'JUNCTION_BOX', label: 'Junction Box', category: 'junction', wallMounted: false }, + { type: 'LIGHT_CEILING', label: 'Ceiling Light', category: 'light', wallMounted: false, coverageRadius: 2.0 }, + { type: 'LIGHT_WALL', label: 'Wall Light', category: 'light', wallMounted: true, coverageRadius: 1.5 }, + { type: 'CABLE_ROUTE', label: 'Cable Route', category: 'cable', wallMounted: false }, +]; + +/** Get the variant from an electrical item's metadata. */ +export function getElectricalVariant(metadata: Record | null): string { + if (metadata && typeof metadata['variant'] === 'string') { + return metadata['variant']; + } + return 'single'; +} diff --git a/apps/client/src/components/editor/symbols/furniture/BedSilhouette.tsx b/apps/client/src/components/editor/symbols/furniture/BedSilhouette.tsx new file mode 100644 index 0000000..588764c --- /dev/null +++ b/apps/client/src/components/editor/symbols/furniture/BedSilhouette.tsx @@ -0,0 +1,81 @@ +import { Group, Rect, Line } from 'react-konva'; + +interface BedSilhouetteProps { + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly rotation: number; + readonly color: string; + readonly fillColor: string; +} + +/** Top-down bed silhouette: rectangle with pillow(s) at head end. */ +export function BedSilhouette({ x, y, width, depth, rotation, color, fillColor }: BedSilhouetteProps) { + const halfW = width / 2; + const halfD = depth / 2; + const pillowH = depth * 0.12; + const pillowInset = width * 0.05; + const pillowGap = width > 1.5 ? width * 0.03 : 0; + const pillowCount = width >= 1.5 ? 2 : 1; + + return ( + + {/* Bed frame */} + + {/* Pillows */} + {pillowCount === 1 ? ( + + ) : ( + <> + + + + )} + {/* Divider line for blanket area */} + + + ); +} diff --git a/apps/client/src/components/editor/symbols/furniture/ChairSilhouette.tsx b/apps/client/src/components/editor/symbols/furniture/ChairSilhouette.tsx new file mode 100644 index 0000000..a9d5a03 --- /dev/null +++ b/apps/client/src/components/editor/symbols/furniture/ChairSilhouette.tsx @@ -0,0 +1,47 @@ +import { Group, Rect } from 'react-konva'; + +interface ChairSilhouetteProps { + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly rotation: number; + readonly color: string; + readonly fillColor: string; +} + +/** Top-down chair silhouette: seat square + backrest line. */ +export function ChairSilhouette({ x, y, width, depth, rotation, color, fillColor }: ChairSilhouetteProps) { + const halfW = width / 2; + const halfD = depth / 2; + const backD = depth * 0.15; + + return ( + + {/* Seat */} + + {/* Backrest */} + + + ); +} diff --git a/apps/client/src/components/editor/symbols/furniture/DeskSilhouette.tsx b/apps/client/src/components/editor/symbols/furniture/DeskSilhouette.tsx new file mode 100644 index 0000000..1531ffc --- /dev/null +++ b/apps/client/src/components/editor/symbols/furniture/DeskSilhouette.tsx @@ -0,0 +1,48 @@ +import { Group, Rect, Line } from 'react-konva'; + +interface DeskSilhouetteProps { + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly rotation: number; + readonly color: string; + readonly fillColor: string; +} + +/** Top-down desk silhouette: rectangle with chair indent area. */ +export function DeskSilhouette({ x, y, width, depth, rotation, color, fillColor }: DeskSilhouetteProps) { + const halfW = width / 2; + const halfD = depth / 2; + + return ( + + {/* Desk surface */} + + {/* Drawer line */} + + {/* Drawer handle dot */} + + + ); +} diff --git a/apps/client/src/components/editor/symbols/furniture/ShelfSilhouette.tsx b/apps/client/src/components/editor/symbols/furniture/ShelfSilhouette.tsx new file mode 100644 index 0000000..47c51e8 --- /dev/null +++ b/apps/client/src/components/editor/symbols/furniture/ShelfSilhouette.tsx @@ -0,0 +1,48 @@ +import { Group, Rect, Line } from 'react-konva'; + +interface ShelfSilhouetteProps { + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly rotation: number; + readonly color: string; + readonly fillColor: string; +} + +/** Top-down shelf/bookcase silhouette: rectangle with horizontal shelf lines. */ +export function ShelfSilhouette({ x, y, width, depth, rotation, color, fillColor }: ShelfSilhouetteProps) { + const halfW = width / 2; + const halfD = depth / 2; + const shelfCount = 3; + + const shelfLines: number[] = []; + for (let i = 1; i <= shelfCount; i++) { + const yPos = -halfD + (depth / (shelfCount + 1)) * i; + shelfLines.push(yPos); + } + + return ( + + + {shelfLines.map((sy, idx) => ( + + ))} + + ); +} diff --git a/apps/client/src/components/editor/symbols/furniture/SofaSilhouette.tsx b/apps/client/src/components/editor/symbols/furniture/SofaSilhouette.tsx new file mode 100644 index 0000000..2f28bbc --- /dev/null +++ b/apps/client/src/components/editor/symbols/furniture/SofaSilhouette.tsx @@ -0,0 +1,65 @@ +import { Group, Rect } from 'react-konva'; + +interface SofaSilhouetteProps { + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly rotation: number; + readonly color: string; + readonly fillColor: string; +} + +/** Top-down sofa silhouette: rectangle with backrest and armrests. */ +export function SofaSilhouette({ x, y, width, depth, rotation, color, fillColor }: SofaSilhouetteProps) { + const halfW = width / 2; + const halfD = depth / 2; + const armW = depth * 0.2; + const backD = depth * 0.22; + + return ( + + {/* Main sofa body */} + + {/* Backrest */} + + {/* Left armrest */} + + {/* Right armrest */} + + + ); +} diff --git a/apps/client/src/components/editor/symbols/furniture/TableSilhouette.tsx b/apps/client/src/components/editor/symbols/furniture/TableSilhouette.tsx new file mode 100644 index 0000000..afa1e63 --- /dev/null +++ b/apps/client/src/components/editor/symbols/furniture/TableSilhouette.tsx @@ -0,0 +1,40 @@ +import { Group, Rect, Circle } from 'react-konva'; + +interface TableSilhouetteProps { + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly rotation: number; + readonly color: string; + readonly fillColor: string; +} + +/** Top-down dining table silhouette: rectangle with rounded corners + leg dots. */ +export function TableSilhouette({ x, y, width, depth, rotation, color, fillColor }: TableSilhouetteProps) { + const halfW = width / 2; + const halfD = depth / 2; + const legInset = Math.min(width, depth) * 0.1; + const legR = Math.min(width, depth) * 0.03; + + return ( + + + {/* Four leg dots */} + + + + + + ); +} diff --git a/apps/client/src/components/editor/symbols/furniture/TvSilhouette.tsx b/apps/client/src/components/editor/symbols/furniture/TvSilhouette.tsx new file mode 100644 index 0000000..997638e --- /dev/null +++ b/apps/client/src/components/editor/symbols/furniture/TvSilhouette.tsx @@ -0,0 +1,47 @@ +import { Group, Rect, Line } from 'react-konva'; + +interface TvSilhouetteProps { + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly rotation: number; + readonly color: string; + readonly fillColor: string; +} + +/** Top-down TV silhouette: thin rectangle with screen indicator. */ +export function TvSilhouette({ x, y, width, depth, rotation, color, fillColor }: TvSilhouetteProps) { + const halfW = width / 2; + const halfD = depth / 2; + + return ( + + {/* TV body */} + + {/* Screen line */} + + {/* Stand center mark */} + + + ); +} diff --git a/apps/client/src/components/editor/symbols/furniture/WardrobeSilhouette.tsx b/apps/client/src/components/editor/symbols/furniture/WardrobeSilhouette.tsx new file mode 100644 index 0000000..09a0d5b --- /dev/null +++ b/apps/client/src/components/editor/symbols/furniture/WardrobeSilhouette.tsx @@ -0,0 +1,54 @@ +import { Group, Rect, Line } from 'react-konva'; + +interface WardrobeSilhouetteProps { + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly rotation: number; + readonly color: string; + readonly fillColor: string; +} + +/** Top-down wardrobe silhouette: rectangle with center door line and handles. */ +export function WardrobeSilhouette({ x, y, width, depth, rotation, color, fillColor }: WardrobeSilhouetteProps) { + const halfW = width / 2; + const halfD = depth / 2; + + return ( + + + {/* Center divider (door split) */} + + {/* Door handles */} + + + + ); +} diff --git a/apps/client/src/components/editor/symbols/furniture/index.ts b/apps/client/src/components/editor/symbols/furniture/index.ts new file mode 100644 index 0000000..b36637c --- /dev/null +++ b/apps/client/src/components/editor/symbols/furniture/index.ts @@ -0,0 +1,46 @@ +export { BedSilhouette } from './BedSilhouette'; +export { DeskSilhouette } from './DeskSilhouette'; +export { WardrobeSilhouette } from './WardrobeSilhouette'; +export { SofaSilhouette } from './SofaSilhouette'; +export { TableSilhouette } from './TableSilhouette'; +export { ChairSilhouette } from './ChairSilhouette'; +export { ShelfSilhouette } from './ShelfSilhouette'; +export { TvSilhouette } from './TvSilhouette'; + +import type { FurnitureType } from '@house-plan-maker/shared'; + +/** Default dimensions for each furniture type (width x depth x height in meters). */ +export interface FurnitureDef { + readonly type: FurnitureType; + readonly label: string; + readonly width: number; + readonly depth: number; + readonly height: number; + readonly icon: string; +} + +export const FURNITURE_DEFS: readonly FurnitureDef[] = [ + { type: 'BED', label: 'Single Bed', width: 1.0, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' }, + { type: 'BED', label: 'Double Bed', width: 1.4, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' }, + { type: 'BED', label: 'Queen Bed', width: 1.6, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' }, + { type: 'BED', label: 'King Bed', width: 1.8, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' }, + { type: 'DESK', label: 'Desk', width: 1.2, depth: 0.6, height: 0.75, icon: '\u{1F4BC}' }, + { type: 'WARDROBE', label: 'Wardrobe (S)', width: 1.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' }, + { type: 'WARDROBE', label: 'Wardrobe (M)', width: 1.5, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' }, + { type: 'WARDROBE', label: 'Wardrobe (L)', width: 2.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' }, + { type: 'SOFA', label: 'Sofa', width: 2.0, depth: 0.9, height: 0.8, icon: '\u{1FA91}' }, + { type: 'TABLE', label: 'Dining Table', width: 1.2, depth: 0.8, height: 0.75, icon: '\u{1F37D}' }, + { type: 'CHAIR', label: 'Chair', width: 0.45, depth: 0.45, height: 0.85, icon: '\u{1FA91}' }, + { type: 'SHELF', label: 'Tall Shelf', width: 0.8, depth: 0.3, height: 1.8, icon: '\u{1F4DA}' }, + { type: 'SHELF', label: 'Wall Shelf 60', width: 0.6, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' }, + { type: 'SHELF', label: 'Wall Shelf 80', width: 0.8, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' }, + { type: 'SHELF', label: 'Wall Shelf 120', width: 1.2, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' }, + { type: 'NIGHTSTAND', label: 'Nightstand', width: 0.5, depth: 0.4, height: 0.5, icon: '\u{1F4E6}' }, + { type: 'DRESSER', label: 'Dresser', width: 1.0, depth: 0.5, height: 0.8, icon: '\u{1F3EA}' }, + { type: 'BOOKCASE', label: 'Bookcase', width: 0.8, depth: 0.3, height: 2.0, icon: '\u{1F4DA}' }, + { type: 'TV', label: 'TV 32"', width: 0.73, depth: 0.08, height: 0.43, icon: '\u{1F4FA}' }, + { type: 'TV', label: 'TV 43"', width: 0.97, depth: 0.08, height: 0.57, icon: '\u{1F4FA}' }, + { type: 'TV', label: 'TV 55"', width: 1.24, depth: 0.08, height: 0.72, icon: '\u{1F4FA}' }, + { type: 'TV', label: 'TV 65"', width: 1.46, depth: 0.08, height: 0.84, icon: '\u{1F4FA}' }, + { type: 'AC_UNIT', label: 'AC Unit', width: 0.85, depth: 0.2, height: 0.3, icon: '\u{2744}' }, +]; diff --git a/apps/client/src/components/editor/templates/TemplatePicker.tsx b/apps/client/src/components/editor/templates/TemplatePicker.tsx new file mode 100644 index 0000000..3e0e3c9 --- /dev/null +++ b/apps/client/src/components/editor/templates/TemplatePicker.tsx @@ -0,0 +1,110 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '../../ui/Modal'; +import { ROOM_TEMPLATES, templateToCreateRoomDto } from './roomTemplates'; +import type { RoomTemplate } from './roomTemplates'; +import type { CreateRoomDto } from '@house-plan-maker/shared'; +import styles from './template-picker.module.css'; + +const TEMPLATE_NAME_KEYS: Record = { + bedroom: 'templates.bedroom', + kitchen: 'templates.kitchen', + bathroom: 'templates.bathroom', + 'living-room': 'templates.livingRoom', + office: 'templates.office', + empty: 'templates.emptyRoom', +}; + +const TEMPLATE_DESC_KEYS: Record = { + bedroom: 'templates.bedroomDesc', + kitchen: 'templates.kitchenDesc', + bathroom: 'templates.bathroomDesc', + 'living-room': 'templates.livingRoomDesc', + office: 'templates.officeDesc', + empty: 'templates.emptyRoomDesc', +}; + +interface TemplatePickerProps { + readonly open: boolean; + readonly onClose: () => void; + readonly onCreateRoom: (dto: CreateRoomDto) => void; + readonly roomCount: number; +} + +export function TemplatePicker({ open, onClose, onCreateRoom, roomCount }: TemplatePickerProps) { + const { t } = useTranslation(); + const [selectedTemplate, setSelectedTemplate] = useState(ROOM_TEMPLATES[0]); + const [roomName, setRoomName] = useState(''); + const [isCreating, setIsCreating] = useState(false); + + const handleCreate = useCallback(() => { + setIsCreating(true); + const name = roomName.trim() || selectedTemplate.name; + const dto = templateToCreateRoomDto(selectedTemplate, name, roomCount); + onCreateRoom(dto); + setIsCreating(false); + setRoomName(''); + onClose(); + }, [selectedTemplate, roomName, roomCount, onCreateRoom, onClose]); + + const footer = ( +
+ + +
+ ); + + return ( + +
+ {/* Template grid */} +
+ {ROOM_TEMPLATES.map((template) => ( + + ))} +
+ + {/* Room name input */} +
+
{t('templates.roomName')}
+ setRoomName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreate(); + } + }} + /> +
+
+
+ ); +} diff --git a/apps/client/src/components/editor/templates/__tests__/roomTemplates.test.ts b/apps/client/src/components/editor/templates/__tests__/roomTemplates.test.ts new file mode 100644 index 0000000..2d06c4f --- /dev/null +++ b/apps/client/src/components/editor/templates/__tests__/roomTemplates.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { ROOM_TEMPLATES, templateToCreateRoomDto } from '../roomTemplates'; + +describe('ROOM_TEMPLATES', () => { + it('has at least 5 templates', () => { + expect(ROOM_TEMPLATES.length).toBeGreaterThanOrEqual(5); + }); + + it('all templates have required fields', () => { + for (const template of ROOM_TEMPLATES) { + expect(template.id).toBeTruthy(); + expect(template.name).toBeTruthy(); + expect(template.description).toBeTruthy(); + expect(template.defaultWidth).toBeGreaterThan(0); + expect(template.defaultHeight).toBeGreaterThan(0); + expect(template.wallHeight).toBeGreaterThan(0); + expect(template.shape.length).toBeGreaterThanOrEqual(3); + } + }); + + it('has unique template ids', () => { + const ids = ROOM_TEMPLATES.map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('shapes form closed polygons with correct dimensions', () => { + for (const template of ROOM_TEMPLATES) { + const xs = template.shape.map((p) => p.x); + const ys = template.shape.map((p) => p.y); + const width = Math.max(...xs) - Math.min(...xs); + const height = Math.max(...ys) - Math.min(...ys); + expect(width).toBeCloseTo(template.defaultWidth); + expect(height).toBeCloseTo(template.defaultHeight); + } + }); +}); + +describe('templateToCreateRoomDto', () => { + it('uses template name by default', () => { + const dto = templateToCreateRoomDto(ROOM_TEMPLATES[0]); + expect(dto.name).toBe(ROOM_TEMPLATES[0].name); + }); + + it('uses custom name when provided', () => { + const dto = templateToCreateRoomDto(ROOM_TEMPLATES[0], 'Custom Name'); + expect(dto.name).toBe('Custom Name'); + }); + + it('includes shape from template', () => { + const template = ROOM_TEMPLATES[0]; + const dto = templateToCreateRoomDto(template); + expect(dto.shape).toHaveLength(template.shape.length); + expect(dto.width).toBe(template.defaultWidth); + expect(dto.height).toBe(template.defaultHeight); + expect(dto.wallHeight).toBe(template.wallHeight); + }); + + it('uses custom order', () => { + const dto = templateToCreateRoomDto(ROOM_TEMPLATES[0], undefined, 5); + expect(dto.order).toBe(5); + }); + + it('defaults order to 0', () => { + const dto = templateToCreateRoomDto(ROOM_TEMPLATES[0]); + expect(dto.order).toBe(0); + }); +}); diff --git a/apps/client/src/components/editor/templates/roomTemplates.ts b/apps/client/src/components/editor/templates/roomTemplates.ts new file mode 100644 index 0000000..7fc2e42 --- /dev/null +++ b/apps/client/src/components/editor/templates/roomTemplates.ts @@ -0,0 +1,118 @@ +import type { Point, CreateRoomDto } from '@house-plan-maker/shared'; + +export interface RoomTemplate { + readonly id: string; + readonly name: string; + readonly description: string; + readonly icon: string; + readonly defaultWidth: number; + readonly defaultHeight: number; + readonly wallHeight: number; + readonly shape: readonly Point[]; + /** Suggested door positions (positionAlongWall on wall index) */ + readonly suggestedDoors: readonly { readonly wallIndex: number; readonly position: number }[]; + /** Suggested window positions */ + readonly suggestedWindows: readonly { readonly wallIndex: number; readonly position: number }[]; +} + +function rectangularShape(width: number, height: number): readonly Point[] { + return [ + { x: 0, y: 0 }, + { x: width, y: 0 }, + { x: width, y: height }, + { x: 0, y: height }, + ]; +} + +export const ROOM_TEMPLATES: readonly RoomTemplate[] = [ + { + id: 'bedroom', + name: 'Bedroom', + description: 'Standard bedroom (4m x 3.5m) with door and window', + icon: '\u{1F6CF}', + defaultWidth: 4, + defaultHeight: 3.5, + wallHeight: 2.7, + shape: rectangularShape(4, 3.5), + suggestedDoors: [{ wallIndex: 3, position: 1 }], + suggestedWindows: [{ wallIndex: 1, position: 2 }], + }, + { + id: 'kitchen', + name: 'Kitchen', + description: 'Kitchen (3.5m x 3m) with door', + icon: '\u{1F373}', + defaultWidth: 3.5, + defaultHeight: 3, + wallHeight: 2.7, + shape: rectangularShape(3.5, 3), + suggestedDoors: [{ wallIndex: 3, position: 1.5 }], + suggestedWindows: [{ wallIndex: 1, position: 1.5 }], + }, + { + id: 'bathroom', + name: 'Bathroom', + description: 'Bathroom (2.5m x 2m)', + icon: '\u{1F6C1}', + defaultWidth: 2.5, + defaultHeight: 2, + wallHeight: 2.7, + shape: rectangularShape(2.5, 2), + suggestedDoors: [{ wallIndex: 3, position: 1 }], + suggestedWindows: [], + }, + { + id: 'living-room', + name: 'Living Room', + description: 'Spacious living room (5m x 4m) with window', + icon: '\u{1F6CB}', + defaultWidth: 5, + defaultHeight: 4, + wallHeight: 2.7, + shape: rectangularShape(5, 4), + suggestedDoors: [{ wallIndex: 3, position: 1 }], + suggestedWindows: [{ wallIndex: 1, position: 2 }, { wallIndex: 2, position: 2.5 }], + }, + { + id: 'office', + name: 'Office', + description: 'Home office (3m x 2.5m)', + icon: '\u{1F4BB}', + defaultWidth: 3, + defaultHeight: 2.5, + wallHeight: 2.7, + shape: rectangularShape(3, 2.5), + suggestedDoors: [{ wallIndex: 3, position: 1.25 }], + suggestedWindows: [{ wallIndex: 1, position: 1.25 }], + }, + { + id: 'empty', + name: 'Empty Room', + description: 'Custom empty room (3m x 3m)', + icon: '\u25A1', + defaultWidth: 3, + defaultHeight: 3, + wallHeight: 2.7, + shape: rectangularShape(3, 3), + suggestedDoors: [], + suggestedWindows: [], + }, +]; + +/** + * Convert a room template into a CreateRoomDto. + */ +export function templateToCreateRoomDto( + template: RoomTemplate, + name?: string, + order?: number, +): CreateRoomDto { + return { + name: name ?? template.name, + shape: [...template.shape], + width: template.defaultWidth, + height: template.defaultHeight, + wallHeight: template.wallHeight, + order: order ?? 0, + }; +} diff --git a/apps/client/src/components/editor/templates/template-picker.module.css b/apps/client/src/components/editor/templates/template-picker.module.css new file mode 100644 index 0000000..0e6945f --- /dev/null +++ b/apps/client/src/components/editor/templates/template-picker.module.css @@ -0,0 +1,121 @@ +.content { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 420px; +} + +.grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-3); +} + +.templateCard { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-3); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg); + cursor: pointer; + transition: all var(--transition-fast); + font-family: var(--font-family); +} + +.templateCard:hover { + border-color: var(--color-accent-300); + background-color: var(--color-accent-50); +} + +.templateCardSelected { + border-color: var(--color-accent-500); + background-color: var(--color-accent-50); + box-shadow: 0 0 0 2px var(--color-accent-200); +} + +.templateIcon { + font-size: 28px; + line-height: 1; +} + +.templateName { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); +} + +.templateDesc { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + text-align: center; +} + +.nameInput { + width: 100%; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-family: var(--font-family); + background: var(--color-bg); + color: var(--color-text-primary); +} + +.nameInput:focus { + outline: 2px solid var(--color-accent-400); + outline-offset: -1px; +} + +.fieldLabel { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + margin-bottom: var(--space-1); +} + +.footerButtons { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} + +.cancelBtn { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: none; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + font-family: var(--font-family); +} + +.cancelBtn:hover { + background-color: var(--color-bg-hover); +} + +.createBtn { + padding: var(--space-2) var(--space-4); + border: none; + border-radius: var(--radius-md); + background-color: var(--color-accent-600); + color: var(--color-text-on-accent); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + font-family: var(--font-family); + transition: background var(--transition-fast); +} + +.createBtn:hover:not(:disabled) { + background-color: var(--color-accent-700); +} + +.createBtn:disabled { + opacity: 0.6; + cursor: default; +} diff --git a/apps/client/src/components/editor/three/CameraControls.tsx b/apps/client/src/components/editor/three/CameraControls.tsx new file mode 100644 index 0000000..2af01fa --- /dev/null +++ b/apps/client/src/components/editor/three/CameraControls.tsx @@ -0,0 +1,161 @@ +import { useMemo, useRef } from 'react'; +import { useThree } from '@react-three/fiber'; +import { OrbitControls } from '@react-three/drei'; +import type { Point } from '@house-plan-maker/shared'; +import { boundingBox } from '../utils/geometry'; + +interface CameraControlsProps { + readonly shape: readonly Point[]; + readonly wallHeight: number; +} + +export type CameraPreset = 'birds-eye' | 'eye-level' | 'corner-ne' | 'corner-nw' | 'corner-se' | 'corner-sw'; + +interface PresetConfig { + readonly position: [number, number, number]; + readonly target: [number, number, number]; +} + +function computePresets( + shape: readonly Point[], + wallHeight: number, +): Record { + const bb = boundingBox(shape); + const centerX = (bb.minX + bb.maxX) / 2; + const centerZ = (bb.minY + bb.maxY) / 2; + const centerY = wallHeight / 2; + + const sizeX = bb.maxX - bb.minX; + const sizeZ = bb.maxY - bb.minY; + const maxSize = Math.max(sizeX, sizeZ, 2); + const dist = maxSize * 1.5; + + const target: [number, number, number] = [centerX, centerY, centerZ]; + const floorTarget: [number, number, number] = [centerX, 0, centerZ]; + + return { + 'birds-eye': { + position: [centerX, dist * 1.5, centerZ + 0.01], + target: floorTarget, + }, + 'eye-level': { + position: [centerX - dist, 1.6, centerZ], + target: [centerX, 1.6, centerZ], + }, + 'corner-ne': { + position: [bb.maxX + dist * 0.6, wallHeight + dist * 0.4, bb.minY - dist * 0.6], + target, + }, + 'corner-nw': { + position: [bb.minX - dist * 0.6, wallHeight + dist * 0.4, bb.minY - dist * 0.6], + target, + }, + 'corner-se': { + position: [bb.maxX + dist * 0.6, wallHeight + dist * 0.4, bb.maxY + dist * 0.6], + target, + }, + 'corner-sw': { + position: [bb.minX - dist * 0.6, wallHeight + dist * 0.4, bb.maxY + dist * 0.6], + target, + }, + }; +} + +export function CameraPresetsUI({ + shape: _shape, + wallHeight: _wallHeight, + onPreset, +}: { + readonly shape: readonly Point[]; + readonly wallHeight: number; + readonly onPreset: (preset: CameraPreset) => void; +}) { + const presetLabels: readonly { key: CameraPreset; label: string }[] = [ + { key: 'birds-eye', label: 'Bird\'s Eye' }, + { key: 'eye-level', label: 'Eye Level' }, + { key: 'corner-ne', label: 'NE Corner' }, + { key: 'corner-nw', label: 'NW Corner' }, + { key: 'corner-se', label: 'SE Corner' }, + { key: 'corner-sw', label: 'SW Corner' }, + ]; + + return ( +
+ {presetLabels.map(({ key, label }) => ( + + ))} +
+ ); +} + +export function SceneCamera({ shape, wallHeight, activePreset }: CameraControlsProps & { readonly activePreset: CameraPreset | null }) { + const { camera } = useThree(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const controlsRef = useRef(null); + + const presets = useMemo( + () => computePresets(shape, wallHeight), + [shape, wallHeight], + ); + + // Apply preset when it changes + const lastApplied = useRef(null); + + if (activePreset && activePreset !== lastApplied.current) { + lastApplied.current = activePreset; + const preset = presets[activePreset]; + if (preset) { + camera.position.set(...preset.position); + camera.lookAt(...preset.target); + if (controlsRef.current) { + controlsRef.current.target.set(...preset.target); + controlsRef.current.update(); + } + } + } + + // Set camera to bird's eye on every mount + const initialized = useRef(false); + if (!initialized.current && !activePreset) { + initialized.current = true; + const defaultPreset = presets['birds-eye']; + camera.position.set(...defaultPreset.position); + camera.lookAt(...defaultPreset.target); + camera.updateProjectionMatrix(); + } + + return ( + + ); +} diff --git a/apps/client/src/components/editor/three/DoorOpening.tsx b/apps/client/src/components/editor/three/DoorOpening.tsx new file mode 100644 index 0000000..1b8e61d --- /dev/null +++ b/apps/client/src/components/editor/three/DoorOpening.tsx @@ -0,0 +1,87 @@ +import { useMemo } from 'react'; +import type { Wall, WallOpening } from '@house-plan-maker/shared'; +import { + wallRotationY, + positionAlongWall3D, +} from './utils/wallGeometry'; + +interface DoorOpeningProps { + readonly opening: WallOpening; + readonly wall: Wall; + readonly isSelected: boolean; + readonly onSelect?: (id: string) => void; +} + +const FRAME_COLOR = '#8b7355'; +const DOOR_PANEL_COLOR = '#a0522d'; +const FRAME_THICKNESS = 0.03; +const DOOR_PANEL_THICKNESS = 0.04; + +/** Angle (radians) the door panel is shown ajar, to indicate swing direction. */ +const DOOR_AJAR_ANGLE = Math.PI / 6; // 30 degrees + +export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpeningProps) { + const rotY = useMemo(() => wallRotationY(wall), [wall]); + + const [cx, cz] = useMemo( + () => positionAlongWall3D(wall, opening.positionAlongWall), + [wall, opening.positionAlongWall], + ); + + const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR; + const halfWidth = opening.width / 2; + const halfThick = wall.thickness / 2 + 0.005; + + // Door panel rotation based on open direction + const openDir = opening.openDirection ?? 'LEFT'; + const isRight = openDir === 'RIGHT'; + const isInward = openDir === 'INWARD'; + + // Hinge position along the X axis (local frame coordinates) + const hingeX = isRight ? halfWidth : -halfWidth; + // Swing angle sign: inward swings in +Z, others swing in -Z + const swingSign = isInward ? 1 : -1; + const panelRotY = swingSign * DOOR_AJAR_ANGLE * (isRight ? -1 : 1); + // Panel center offset from hinge (half the door width along local X after rotation) + const panelHalfW = opening.width / 2; + const panelOffsetX = isRight + ? -panelHalfW * Math.cos(DOOR_AJAR_ANGLE) + : panelHalfW * Math.cos(DOOR_AJAR_ANGLE); + const panelOffsetZ = swingSign * panelHalfW * Math.sin(DOOR_AJAR_ANGLE); + + return ( + { e.stopPropagation(); onSelect(opening.id); } : undefined} + > + {/* Left frame post */} + + + + + + {/* Right frame post */} + + + + + + {/* Top frame bar (lintel) */} + + + + + + {/* Door panel (shown slightly ajar to indicate swing direction) */} + + + + + + ); +} diff --git a/apps/client/src/components/editor/three/ElectricalMesh.tsx b/apps/client/src/components/editor/three/ElectricalMesh.tsx new file mode 100644 index 0000000..4c71c17 --- /dev/null +++ b/apps/client/src/components/editor/three/ElectricalMesh.tsx @@ -0,0 +1,214 @@ +import { useMemo } from 'react'; +import * as THREE from 'three'; +import type { ElectricalItem, ElectricalType, Wall } from '@house-plan-maker/shared'; +import { wallRotationY, positionAlongWall3D, wallVector, wallNormal } from './utils/wallGeometry'; + +interface ElectricalMeshWithHeightProps { + readonly item: ElectricalItem; + readonly wallMap: ReadonlyMap; + readonly wallHeight: number; + readonly isSelected: boolean; + readonly onSelect?: (id: string) => void; +} + +const ELECTRICAL_COLORS: Record = { + OUTLET: '#f5f5f0', + SWITCH: '#f5f5f0', + JUNCTION_BOX: '#808080', + LIGHT_CEILING: '#fff8dc', + LIGHT_WALL: '#fff8dc', + CABLE_ROUTE: '#ff6b35', +}; + +const SELECTED_COLOR = '#6fa8dc'; + +function degToRad(degrees: number): number { + return (degrees * Math.PI) / 180; +} + +/** Find the wall that this electrical item is attached to via O(1) map lookup. */ +function findWallInMap(wallId: string | null, wallMap: ReadonlyMap): Wall | null { + if (!wallId) return null; + return wallMap.get(wallId) ?? null; +} + +/** Outlet: small rectangular box on wall */ +function OutletMesh({ color }: { readonly color: string }) { + return ( + + + + + ); +} + +/** Switch: slightly taller rectangular box on wall */ +function SwitchMesh({ color }: { readonly color: string }) { + return ( + + + + + + {/* Toggle indicator */} + + + + + + ); +} + +/** Junction box: small gray box */ +function JunctionBoxMesh({ color }: { readonly color: string }) { + return ( + + + + + ); +} + +/** Ceiling light: disc or sphere hanging from ceiling */ +function CeilingLightMesh({ color, wallHeight }: { readonly color: string; readonly wallHeight: number }) { + return ( + + {/* Canopy */} + + + + + {/* Shade / bulb */} + + + + + + ); +} + +/** Wall light: half-sphere attached to wall */ +function WallLightMesh({ color }: { readonly color: string }) { + return ( + + {/* Base plate */} + + + + + {/* Half sphere shade */} + + + + + + ); +} + +/** Cable route: small orange marker */ +function CableRouteMesh({ color }: { readonly color: string }) { + return ( + + + + + ); +} + +/** Compute 3D position for an electrical item. */ +function useElectricalPosition( + item: ElectricalItem, + wall: Wall | null, +): [number, number, number] { + return useMemo<[number, number, number]>(() => { + if (item.type === 'LIGHT_CEILING') { + return [item.x, 0, item.y]; + } + + if (wall && item.wallId) { + const { nx, ny } = wallNormal(wall); + const { length } = wallVector(wall); + const dx = item.x - wall.startX; + const dy = item.y - wall.startY; + const wallDx = wall.endX - wall.startX; + const wallDy = wall.endY - wall.startY; + const t = length > 0 ? (dx * wallDx + dy * wallDy) / (length * length) : 0; + const clampedT = Math.max(0, Math.min(1, t)); + const [wx, wz] = positionAlongWall3D(wall, clampedT * length); + const offset = wall.thickness / 2 + 0.015; + const elevation = item.elevationFromFloor ?? 1.2; + return [wx + nx * offset, elevation, wz + ny * offset]; + } + + const elevation = item.elevationFromFloor ?? 0.3; + return [item.x, elevation, item.y]; + }, [item, wall]); +} + +export function ElectricalMeshWithHeight({ + item, + wallMap, + wallHeight, + isSelected, + onSelect, +}: ElectricalMeshWithHeightProps) { + const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLORS[item.type]; + const wall = useMemo(() => { + // Try exact wallId match first + const exact = findWallInMap(item.wallId, wallMap); + if (exact) return exact; + // Fallback: find nearest wall by proximity (wallId may be stale after save) + if (item.wallId || item.type === 'OUTLET' || item.type === 'SWITCH' || item.type === 'LIGHT_WALL' || item.type === 'JUNCTION_BOX') { + let bestWall: Wall | null = null; + let bestDist = 0.3; // max 30cm threshold + for (const w of wallMap.values()) { + const dx = item.x - w.startX; + const dy = item.y - w.startY; + const wx = w.endX - w.startX; + const wy = w.endY - w.startY; + const len = Math.sqrt(wx * wx + wy * wy); + if (len === 0) continue; + const perpDist = Math.abs(dx * (-wy / len) + dy * (wx / len)); + const along = (dx * wx + dy * wy) / len; + if (perpDist < bestDist && along >= -0.1 && along <= len + 0.1) { + bestDist = perpDist; + bestWall = w; + } + } + return bestWall; + } + return null; + }, [item, wallMap]); + const position = useElectricalPosition(item, wall); + + const rotY = useMemo(() => { + if (wall) return wallRotationY(wall); + return -degToRad(item.rotation); + }, [wall, item.rotation]); + + return ( + { e.stopPropagation(); onSelect(item.id); } : undefined} + > + {item.type === 'OUTLET' && } + {item.type === 'SWITCH' && } + {item.type === 'JUNCTION_BOX' && } + {item.type === 'LIGHT_CEILING' && } + {item.type === 'LIGHT_WALL' && } + {item.type === 'CABLE_ROUTE' && } + + ); +} diff --git a/apps/client/src/components/editor/three/FloorCeiling.tsx b/apps/client/src/components/editor/three/FloorCeiling.tsx new file mode 100644 index 0000000..40962ad --- /dev/null +++ b/apps/client/src/components/editor/three/FloorCeiling.tsx @@ -0,0 +1,195 @@ +import { useMemo } from 'react'; +import * as THREE from 'three'; +import type { Point, FloorType } from '@house-plan-maker/shared'; + +interface FloorCeilingProps { + readonly shape: readonly Point[]; + readonly wallHeight: number; + readonly floorType?: FloorType; +} + +function createPolygonGeometry(shape: readonly Point[]): THREE.ShapeGeometry | null { + if (shape.length < 3) return null; + + const threeShape = new THREE.Shape(); + threeShape.moveTo(shape[0].x, shape[0].y); + for (let i = 1; i < shape.length; i++) { + threeShape.lineTo(shape[i].x, shape[i].y); + } + threeShape.closePath(); + + return new THREE.ShapeGeometry(threeShape); +} + +/** Generate a procedural floor texture on a canvas. */ +function createFloorTexture(floorType: FloorType): THREE.CanvasTexture { + const size = 512; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d')!; + + switch (floorType) { + case 'WOOD_LIGHT': + drawWoodPlanks(ctx, size, '#d4b896', '#c4a87a', '#b89868'); + break; + case 'WOOD_MEDIUM': + drawWoodPlanks(ctx, size, '#a07850', '#8c6840', '#785830'); + break; + case 'WOOD_DARK': + drawWoodPlanks(ctx, size, '#5c3c28', '#4c3020', '#3c2418'); + break; + case 'WOOD_HERRINGBONE': + drawHerringbone(ctx, size, '#b08860', '#9a7850', '#8a6840'); + break; + case 'TILE_WHITE': + drawTiles(ctx, size, '#f0f0f0', '#e0e0e0', '#d8d8d8'); + break; + case 'TILE_GRAY': + drawTiles(ctx, size, '#a0a0a0', '#909090', '#888888'); + break; + case 'LAMINATE': + drawWoodPlanks(ctx, size, '#c8b090', '#b8a080', '#a89070'); + break; + case 'CONCRETE': + default: + drawConcrete(ctx, size); + break; + } + + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.repeat.set(2, 2); + return texture; +} + +function drawWoodPlanks(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, c3: string) { + const plankHeight = size / 6; + + for (let i = 0; i < 6; i++) { + const y = i * plankHeight; + // Alternate plank colors + ctx.fillStyle = i % 2 === 0 ? c1 : c2; + ctx.fillRect(0, y, size, plankHeight); + + // Grain lines + ctx.strokeStyle = c3; + ctx.lineWidth = 0.5; + for (let g = 0; g < 8; g++) { + const gy = y + Math.random() * plankHeight; + ctx.beginPath(); + ctx.moveTo(0, gy); + ctx.bezierCurveTo( + size * 0.3, gy + (Math.random() - 0.5) * 3, + size * 0.7, gy + (Math.random() - 0.5) * 3, + size, gy, + ); + ctx.stroke(); + } + + // Plank gap + ctx.fillStyle = 'rgba(0,0,0,0.15)'; + ctx.fillRect(0, y, size, 1); + + // Vertical joint (staggered) + const jointX = (i % 2 === 0) ? size * 0.4 : size * 0.7; + ctx.fillRect(jointX, y, 1, plankHeight); + } +} + +function drawHerringbone(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, c3: string) { + ctx.fillStyle = c3; + ctx.fillRect(0, 0, size, size); + + const plankW = size / 4; + const plankH = size / 8; + + for (let row = -2; row < size / plankH + 2; row++) { + for (let col = -2; col < size / plankW + 2; col++) { + const isEven = (row + col) % 2 === 0; + ctx.fillStyle = isEven ? c1 : c2; + + ctx.save(); + const cx = col * plankW; + const cy = row * plankH; + ctx.translate(cx + plankW / 2, cy + plankH / 2); + ctx.rotate(isEven ? Math.PI / 4 : -Math.PI / 4); + ctx.fillRect(-plankW / 2, -plankH / 4, plankW, plankH / 2); + ctx.strokeStyle = 'rgba(0,0,0,0.1)'; + ctx.lineWidth = 0.5; + ctx.strokeRect(-plankW / 2, -plankH / 4, plankW, plankH / 2); + ctx.restore(); + } + } +} + +function drawTiles(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, grout: string) { + const tileSize = size / 4; + + // Grout background + ctx.fillStyle = grout; + ctx.fillRect(0, 0, size, size); + + const groutWidth = 3; + + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + ctx.fillStyle = (row + col) % 2 === 0 ? c1 : c2; + ctx.fillRect( + col * tileSize + groutWidth / 2, + row * tileSize + groutWidth / 2, + tileSize - groutWidth, + tileSize - groutWidth, + ); + } + } +} + +function drawConcrete(ctx: CanvasRenderingContext2D, size: number) { + ctx.fillStyle = '#d4d0cc'; + ctx.fillRect(0, 0, size, size); + + // Noise texture + for (let i = 0; i < 5000; i++) { + const x = Math.random() * size; + const y = Math.random() * size; + const brightness = 180 + Math.random() * 40; + ctx.fillStyle = `rgb(${brightness},${brightness - 5},${brightness - 10})`; + ctx.fillRect(x, y, 2, 2); + } +} + +const textureCache = new Map(); + +function getFloorTexture(floorType: FloorType): THREE.CanvasTexture { + let tex = textureCache.get(floorType); + if (!tex) { + tex = createFloorTexture(floorType); + textureCache.set(floorType, tex); + } + return tex; +} + +export function FloorCeiling({ shape, wallHeight, floorType = 'CONCRETE' }: FloorCeilingProps) { + const floorGeometry = useMemo(() => createPolygonGeometry(shape), [shape]); + const texture = useMemo(() => getFloorTexture(floorType), [floorType]); + + if (!floorGeometry) return null; + + return ( + + + + ); +} diff --git a/apps/client/src/components/editor/three/FurnitureMesh.tsx b/apps/client/src/components/editor/three/FurnitureMesh.tsx new file mode 100644 index 0000000..708f858 --- /dev/null +++ b/apps/client/src/components/editor/three/FurnitureMesh.tsx @@ -0,0 +1,341 @@ +import { useMemo } from 'react'; +import * as THREE from 'three'; +import type { FurnitureItem, FurnitureType } from '@house-plan-maker/shared'; + +interface FurnitureMeshProps { + readonly item: FurnitureItem; + readonly isSelected: boolean; + readonly onSelect?: (id: string) => void; +} + +const FURNITURE_COLORS: Record = { + BED: '#8fa5b2', + DESK: '#b08968', + WARDROBE: '#7a6652', + SOFA: '#9b8e7e', + TABLE: '#c4a882', + CHAIR: '#a0937d', + SHELF: '#b09e8a', + NIGHTSTAND: '#9b8b7a', + DRESSER: '#8a7a6a', + BOOKCASE: '#7a6a5a', + TV: '#2a2a3a', + AC_UNIT: '#e8e8e8', + OTHER: '#a0a0a0', +}; + +const SELECTED_COLOR = '#6fa8dc'; +const LEG_COLOR = '#4a3728'; +const LEG_RADIUS = 0.02; +const LEG_SEGMENTS = 6; + +// ── Shared materials (module-level singletons) ── + +const legMaterial = new THREE.MeshStandardMaterial({ color: LEG_COLOR, roughness: 0.6 }); +const legMaterialSmooth = new THREE.MeshStandardMaterial({ color: LEG_COLOR, roughness: 0.5 }); + +const furnitureMaterials: Record = {}; +function getFurnitureMaterial(color: string, roughness: number): THREE.MeshStandardMaterial { + const key = `${color}_${roughness}`; + const existing = furnitureMaterials[key]; + if (existing) return existing; + const mat = new THREE.MeshStandardMaterial({ color, roughness }); + furnitureMaterials[key] = mat; + return mat; +} + +// ── Shared geometries for common shapes ── + +const legGeometry = new THREE.CylinderGeometry(LEG_RADIUS, LEG_RADIUS, 1, LEG_SEGMENTS); +const dividerLineGeometry = new THREE.BoxGeometry(0.005, 1, 0.002); + +function degToRad(degrees: number): number { + return (degrees * Math.PI) / 180; +} + +/** Simple bed: mattress box + headboard */ +function BedMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const mattressHeight = item.height * 0.4; + const frameHeight = item.height * 0.3; + const headboardHeight = item.height; + const mattressMaterial = useMemo(() => getFurnitureMaterial(color, 0.9), [color]); + + return ( + + {/* Frame */} + + + + {/* Mattress */} + + + + {/* Headboard */} + + + + + ); +} + +/** Desk: top slab + 4 legs */ +function DeskMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const topThickness = 0.04; + const legHeight = item.height - topThickness; + const inset = 0.05; + const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]); + + return ( + + {/* Top */} + + + + {/* Legs */} + {[ + [-item.width / 2 + inset, -item.depth / 2 + inset], + [item.width / 2 - inset, -item.depth / 2 + inset], + [-item.width / 2 + inset, item.depth / 2 - inset], + [item.width / 2 - inset, item.depth / 2 - inset], + ].map(([x, z], i) => ( + + + + ))} + + ); +} + +/** Wardrobe: tall box with slight door line */ +function WardrobeMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.6), [color]); + + return ( + + + + + {/* Door divider line */} + + + + + ); +} + +/** Sofa: seat + back (L-shape profile) */ +function SofaMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const seatHeight = item.height * 0.45; + const backHeight = item.height; + const backDepth = item.depth * 0.25; + const sofaMaterial = useMemo(() => getFurnitureMaterial(color, 0.9), [color]); + + return ( + + {/* Seat */} + + + + {/* Backrest */} + + + + {/* Armrests */} + {[-1, 1].map((side) => ( + + + + ))} + + ); +} + +/** Table: top slab + 4 legs */ +function TableMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const topThickness = 0.03; + const legHeight = item.height - topThickness; + const inset = 0.05; + const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]); + + return ( + + + + + {[ + [-item.width / 2 + inset, -item.depth / 2 + inset], + [item.width / 2 - inset, -item.depth / 2 + inset], + [-item.width / 2 + inset, item.depth / 2 - inset], + [item.width / 2 - inset, item.depth / 2 - inset], + ].map(([x, z], i) => ( + + + + ))} + + ); +} + +/** Chair: seat + back + 4 legs */ +function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const seatHeight = item.height * 0.5; + const seatThickness = 0.03; + const legHeight = seatHeight - seatThickness; + const inset = 0.03; + const chairMaterial = useMemo(() => getFurnitureMaterial(color, 0.6), [color]); + + return ( + + {/* Seat */} + + + + {/* Backrest */} + + + + {/* Legs */} + {[ + [-item.width / 2 + inset, -item.depth / 2 + inset], + [item.width / 2 - inset, -item.depth / 2 + inset], + [-item.width / 2 + inset, item.depth / 2 - inset], + [item.width / 2 - inset, item.depth / 2 - inset], + ].map(([x, z], i) => ( + + + + ))} + + ); +} + +/** Shelf / Bookcase / Nightstand / Dresser / Other: simple box */ +function SimpleBoxMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]); + + return ( + + + + ); +} + +/** Bookcase: open shelves */ +function BookcaseMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const shelfCount = Math.max(2, Math.round(item.height / 0.35)); + const panelThickness = 0.02; + const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]); + + return ( + + {/* Back panel */} + + + + {/* Side panels */} + {[-1, 1].map((side) => ( + + + + ))} + {/* Shelves */} + {Array.from({ length: shelfCount + 1 }).map((_, i) => { + const y = (i / shelfCount) * item.height; + return ( + + + + ); + })} + + ); +} + +/** TV: thin screen panel, optionally on a stand */ +function TvMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const screenMaterial = useMemo(() => getFurnitureMaterial('#1a1a2e', 0.1), []); + const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]); + const screenThickness = 0.03; + const hasStand = !item.label?.includes('[no-stand]'); + const standHeight = hasStand ? item.height * 0.15 : 0; + const screenHeight = item.height - standHeight; + + return ( + + {/* Screen */} + + + + {/* Frame border */} + + + + {hasStand && ( + <> + {/* Stand */} + + + + {/* Stand base */} + + + + + )} + + ); +} + +function getFurnitureComponent(type: FurnitureType) { + switch (type) { + case 'BED': return BedMesh; + case 'DESK': return DeskMesh; + case 'WARDROBE': return WardrobeMesh; + case 'SOFA': return SofaMesh; + case 'TABLE': return TableMesh; + case 'CHAIR': return ChairMesh; + case 'BOOKCASE': return BookcaseMesh; + case 'TV': return TvMesh; + case 'AC_UNIT': return SimpleBoxMesh; + case 'SHELF': + case 'NIGHTSTAND': + case 'DRESSER': + case 'OTHER': + default: + return SimpleBoxMesh; + } +} + +export function FurnitureMesh({ item, isSelected, onSelect }: FurnitureMeshProps) { + const Component = useMemo(() => getFurnitureComponent(item.type), [item.type]); + const color = isSelected ? SELECTED_COLOR : FURNITURE_COLORS[item.type]; + + // 2D coords: x,y is top-left corner → compute center for 3D positioning + // 3D: (centerX, 0, centerY), rotation around Y axis + const centerX = item.x + item.width / 2; + const centerY = item.y + item.depth / 2; + return ( + { e.stopPropagation(); onSelect(item.id); } : undefined} + > + + + ); +} diff --git a/apps/client/src/components/editor/three/PlinthMesh.tsx b/apps/client/src/components/editor/three/PlinthMesh.tsx new file mode 100644 index 0000000..7a30be3 --- /dev/null +++ b/apps/client/src/components/editor/three/PlinthMesh.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; +import * as THREE from 'three'; +import type { Wall, WallOpening } from '@house-plan-maker/shared'; +import { + getOpeningSlices, + wallVector, + wallRotationY, + positionAlongWall3D, +} from './utils/wallGeometry'; + +interface PlinthMeshProps { + readonly wall: Wall; + readonly openings: readonly WallOpening[]; + readonly plinthHeight: number; + readonly plinthThickness: number; +} + +const PLINTH_COLOR = '#d4c5b2'; + +// ── Shared plinth material (module-level singleton) ── +const plinthMaterial = new THREE.MeshStandardMaterial({ + color: PLINTH_COLOR, + roughness: 0.6, +}); + +interface PlinthSegment { + readonly startAlongWall: number; + readonly endAlongWall: number; +} + +/** + * Compute plinth segments for a wall — continuous along wall base, + * interrupted at door openings (openings with elevationFromFloor === 0). + */ +function computePlinthSegments( + wall: Wall, + openings: readonly WallOpening[], +): readonly PlinthSegment[] { + const { length } = wallVector(wall); + const slices = getOpeningSlices(wall, openings); + + // Only doors (elevationFromFloor === 0) interrupt the plinth + const doorSlices = slices.filter((s) => s.opening.elevationFromFloor === 0); + + if (doorSlices.length === 0) { + return [{ startAlongWall: 0, endAlongWall: length }]; + } + + const segments: PlinthSegment[] = []; + let cursor = 0; + + for (const slice of doorSlices) { + if (slice.startAlongWall > cursor) { + segments.push({ startAlongWall: cursor, endAlongWall: slice.startAlongWall }); + } + cursor = slice.endAlongWall; + } + + if (cursor < length) { + segments.push({ startAlongWall: cursor, endAlongWall: length }); + } + + return segments; +} + +export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: PlinthMeshProps) { + const segments = useMemo( + () => computePlinthSegments(wall, openings), + [wall, openings], + ); + + const rotY = useMemo(() => wallRotationY(wall), [wall]); + + if (plinthHeight <= 0 || plinthThickness <= 0) return null; + + return ( + + {segments.map((segment, i) => { + const segLen = segment.endAlongWall - segment.startAlongWall; + if (segLen <= 0) return null; + + const midAlongWall = (segment.startAlongWall + segment.endAlongWall) / 2; + const [cx, cz] = positionAlongWall3D(wall, midAlongWall); + + return ( + + + + ); + })} + + ); +} diff --git a/apps/client/src/components/editor/three/Room3DView.tsx b/apps/client/src/components/editor/three/Room3DView.tsx new file mode 100644 index 0000000..2c879bc --- /dev/null +++ b/apps/client/src/components/editor/three/Room3DView.tsx @@ -0,0 +1,259 @@ +import { useState, useCallback, useMemo, useRef, Suspense } from 'react'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { PerspectiveCamera } from '@react-three/drei'; +import * as THREE from 'three'; +import { useEditor } from '../context/EditorContext'; +import { boundingBox } from '../utils/geometry'; +import { FloorCeiling } from './FloorCeiling'; +import { WallMesh } from './WallMesh'; +import { DoorOpening } from './DoorOpening'; +import { WindowOpening } from './WindowOpening'; +import { FurnitureMesh } from './FurnitureMesh'; +import { ElectricalMeshWithHeight } from './ElectricalMesh'; +import { PlinthMesh } from './PlinthMesh'; +import { RoomLabels } from './RoomLabels'; +import { SceneCamera, CameraPresetsUI, type CameraPreset } from './CameraControls'; + +import type { Wall } from '@house-plan-maker/shared'; + +/** Determines the two walls closest to the camera and reports their IDs. */ +function NearestWallTracker({ walls, onUpdate }: { readonly walls: readonly Wall[]; readonly onUpdate: (ids: ReadonlySet) => void }) { + const { camera } = useThree(); + const lastIdsRef = useRef(''); + const frameCountRef = useRef(0); + + useFrame(() => { + // Only check every 10 frames for performance + frameCountRef.current++; + if (frameCountRef.current % 10 !== 0) return; + + const camPos = camera.position; + const camDir = new THREE.Vector3(); + camera.getWorldDirection(camDir); + + // Sort walls by distance to camera + const sorted = walls + .map((wall) => { + const midX = (wall.startX + wall.endX) / 2; + const midZ = (wall.startY + wall.endY) / 2; + const dist = Math.sqrt((camPos.x - midX) ** 2 + (camPos.z - midZ) ** 2); + return { id: wall.id, dist }; + }) + .sort((a, b) => a.dist - b.dist); + + // Don't hide when in bird's eye (camera high above) + const camHeight = camPos.y; + const closestDist = sorted[0]?.dist ?? 0; + const hide = closestDist > 0.5 && camHeight < closestDist * 2; + + const ids = hide + ? new Set(sorted.slice(0, 2).map((s) => s.id)) + : new Set(); + + const key = [...ids].sort().join(','); + if (key !== lastIdsRef.current) { + lastIdsRef.current = key; + onUpdate(ids); + } + }); + + return null; +} + +/** + * Room3DView — read-only 3D perspective view of the room. + * Renders inside a @react-three/fiber Canvas with orbit controls. + */ +export function Room3DView() { + const { state, dispatch } = useEditor(); + const { room, walls, openings, electricalItems, furnitureItems, selectedIds, layerVisibility } = state; + + const [activePreset, setActivePreset] = useState(null); + const [hiddenWallIds, setHiddenWallIds] = useState>(new Set()); + + const handlePreset = useCallback((preset: CameraPreset) => { + setActivePreset(preset); + // Reset after applying so it can be re-applied + setTimeout(() => setActivePreset(null), 100); + }, []); + + const handleSelect = useCallback( + (id: string) => { + dispatch({ type: 'SET_SELECTED', ids: new Set([id]) }); + }, + [dispatch], + ); + + const wallHeight = room.wallHeight; + const plinthHeight = room.plinthHeight; + const plinthThickness = room.plinthThickness; + const shape = room.shape; + + // Separate openings by type + const doors = useMemo( + () => openings.filter((o) => o.type === 'DOOR'), + [openings], + ); + const windows = useMemo( + () => openings.filter((o) => o.type === 'WINDOW'), + [openings], + ); + + // Compute bird's eye camera position from room bounds + const initialCameraPos = useMemo((): [number, number, number] => { + if (shape.length < 3) return [0, 10, 0.01]; + const bb = boundingBox(shape); + const cx = (bb.minX + bb.maxX) / 2; + const cz = (bb.minY + bb.maxY) / 2; + const maxSize = Math.max(bb.maxX - bb.minX, bb.maxY - bb.minY, 2); + return [cx, maxSize * 1.5 * 1.5, cz + 0.01]; + }, [shape]); + + // Wall lookup by ID for openings + const wallMap = useMemo(() => { + const map = new Map(); + for (const w of walls) { + map.set(w.id, w); + } + return map; + }, [walls]); + + return ( +
+ + + + {/* Camera + Controls */} + + + + {/* Lighting */} + + + + + {/* Track nearest wall to camera and hide it */} + + + {/* Floor */} + + + {/* Walls (hide the one facing the camera) */} + {layerVisibility.walls && walls.map((wall) => ( + hiddenWallIds.has(wall.id) ? null : ( + + ) + ))} + + {/* Plinths (hide matching wall's plinth too) */} + {layerVisibility.walls && walls.map((wall) => ( + hiddenWallIds.has(wall.id) ? null : ( + + ) + ))} + + {/* Door openings */} + {doors.map((door) => { + const wall = wallMap.get(door.wallId); + if (!wall) return null; + return ( + + ); + })} + + {/* Window openings */} + {windows.map((win) => { + const wall = wallMap.get(win.wallId); + if (!wall) return null; + return ( + + ); + })} + + {/* Furniture */} + {layerVisibility.furniture && furnitureItems.map((item) => ( + + ))} + + {/* Electrical */} + {layerVisibility.electrical && electricalItems.map((item) => ( + + ))} + + {/* Room labels and dimensions */} + + + {/* Contact shadows on the floor */} + {/* ContactShadows removed — floor is handled by FloorCeiling */} + + +
+ ); +} diff --git a/apps/client/src/components/editor/three/RoomLabels.tsx b/apps/client/src/components/editor/three/RoomLabels.tsx new file mode 100644 index 0000000..86c424e --- /dev/null +++ b/apps/client/src/components/editor/three/RoomLabels.tsx @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import { Html } from '@react-three/drei'; +import type { Point, Wall } from '@house-plan-maker/shared'; +import { boundingBox } from '../utils/geometry'; +import { wallVector } from './utils/wallGeometry'; + +interface RoomLabelsProps { + readonly roomName: string; + readonly shape: readonly Point[]; + readonly walls: readonly Wall[]; + readonly wallHeight: number; +} + +function formatLength(meters: number): string { + if (meters >= 1) { + return `${meters.toFixed(2)} m`; + } + return `${(meters * 100).toFixed(0)} cm`; +} + +export function RoomLabels({ roomName, shape, walls, wallHeight }: RoomLabelsProps) { + const bb = useMemo(() => boundingBox(shape), [shape]); + const centerX = (bb.minX + bb.maxX) / 2; + const centerZ = (bb.minY + bb.maxY) / 2; + + return ( + + {/* Room name label floating above center */} + +
+ {roomName} +
+ + + {/* Wall dimension labels */} + {walls.map((wall) => { + const { length } = wallVector(wall); + if (length < 0.1) return null; + + const midX = (wall.startX + wall.endX) / 2; + const midZ = (wall.startY + wall.endY) / 2; + + return ( + +
+ {formatLength(length)} +
+ + ); + })} +
+ ); +} diff --git a/apps/client/src/components/editor/three/WallMesh.tsx b/apps/client/src/components/editor/three/WallMesh.tsx new file mode 100644 index 0000000..74b11cd --- /dev/null +++ b/apps/client/src/components/editor/three/WallMesh.tsx @@ -0,0 +1,105 @@ +import { useMemo } from 'react'; +import * as THREE from 'three'; +import type { Wall, WallOpening } from '@house-plan-maker/shared'; +import { + splitWallAroundOpenings, + wallRotationY, + wallSegmentCenter3D, + type WallSegment, +} from './utils/wallGeometry'; + +interface WallMeshProps { + readonly wall: Wall; + readonly openings: readonly WallOpening[]; + readonly wallHeight: number; + readonly wallColor?: string; + readonly selectedIds: ReadonlySet; + readonly onSelect?: (id: string) => void; +} + +const DEFAULT_WALL_COLOR = '#f0ebe3'; +const WALL_SELECTED_COLOR = '#b8d4e3'; + +// ── Wall material cache ── +const wallMaterialCache = new Map(); + +function getWallMaterial(color: string): THREE.MeshStandardMaterial { + let mat = wallMaterialCache.get(color); + if (!mat) { + mat = new THREE.MeshStandardMaterial({ color, roughness: 0.7, side: THREE.DoubleSide }); + wallMaterialCache.set(color, mat); + } + return mat; +} + +const wallSelectedMaterial = new THREE.MeshStandardMaterial({ + color: WALL_SELECTED_COLOR, + roughness: 0.7, + side: THREE.DoubleSide, +}); + +function WallSegmentMesh({ + wall, + segment, + thickness, + wallColor, + isSelected, + onSelect, +}: { + readonly wall: Wall; + readonly segment: WallSegment; + readonly thickness: number; + readonly wallColor: string; + readonly isSelected: boolean; + readonly onSelect?: (id: string) => void; +}) { + const segmentWidth = segment.endAlongWall - segment.startAlongWall; + const segmentHeight = segment.topY - segment.bottomY; + + const center = useMemo( + () => wallSegmentCenter3D(wall, segment), + [wall, segment], + ); + + const rotY = useMemo(() => wallRotationY(wall), [wall]); + + if (segmentWidth <= 0 || segmentHeight <= 0) return null; + + return ( + { e.stopPropagation(); onSelect(wall.id); } : undefined} + > + + + ); +} + +export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_COLOR, selectedIds, onSelect }: WallMeshProps) { + const segments = useMemo( + () => splitWallAroundOpenings(wall, openings, wallHeight), + [wall, openings, wallHeight], + ); + + const isSelected = selectedIds.has(wall.id); + + return ( + + {segments.map((segment, i) => ( + + ))} + + ); +} diff --git a/apps/client/src/components/editor/three/WindowOpening.tsx b/apps/client/src/components/editor/three/WindowOpening.tsx new file mode 100644 index 0000000..4decf05 --- /dev/null +++ b/apps/client/src/components/editor/three/WindowOpening.tsx @@ -0,0 +1,86 @@ +import { useMemo } from 'react'; +import * as THREE from 'three'; +import type { Wall, WallOpening } from '@house-plan-maker/shared'; +import { + wallRotationY, + positionAlongWall3D, +} from './utils/wallGeometry'; + +interface WindowOpeningProps { + readonly opening: WallOpening; + readonly wall: Wall; + readonly isSelected: boolean; + readonly onSelect?: (id: string) => void; +} + +const FRAME_COLOR = '#c0c0c0'; +const GLASS_COLOR = '#a8d8ea'; +const FRAME_THICKNESS = 0.03; + +export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpeningProps) { + const rotY = useMemo(() => wallRotationY(wall), [wall]); + + const [cx, cz] = useMemo( + () => positionAlongWall3D(wall, opening.positionAlongWall), + [wall, opening.positionAlongWall], + ); + + const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR; + const halfWidth = opening.width / 2; + const halfThick = wall.thickness / 2 + 0.005; + + return ( + { e.stopPropagation(); onSelect(opening.id); } : undefined} + > + {/* Window frame — four sides */} + {/* Left */} + + + + + {/* Right */} + + + + + {/* Top */} + + + + + {/* Bottom (sill) */} + + + + + + {/* Glass pane */} + + + + + + {/* Center cross divider — vertical */} + + + + + + {/* Center cross divider — horizontal */} + + + + + + ); +} diff --git a/apps/client/src/components/editor/three/utils/wallGeometry.ts b/apps/client/src/components/editor/three/utils/wallGeometry.ts new file mode 100644 index 0000000..77560b1 --- /dev/null +++ b/apps/client/src/components/editor/three/utils/wallGeometry.ts @@ -0,0 +1,170 @@ +import type { Point, Wall, WallOpening } from '@house-plan-maker/shared'; + +/** + * Coordinate conversion: 2D editor (X-right, Y-down) -> Three.js (X-right, Y-up, Z-forward). + * 2D (x, y) -> 3D (x, 0, y) for floor plane; Y axis is height. + */ +export function toThreeCoords(point: Point): [number, number, number] { + return [point.x, 0, point.y]; +} + +/** Wall direction vector and length in 2D. */ +export function wallVector(wall: Wall): { dx: number; dy: number; length: number } { + const dx = wall.endX - wall.startX; + const dy = wall.endY - wall.startY; + const length = Math.sqrt(dx * dx + dy * dy); + return { dx, dy, length }; +} + +/** Wall perpendicular normal (unit vector pointing "left" of wall direction). */ +export function wallNormal(wall: Wall): { nx: number; ny: number } { + const { dx, dy, length } = wallVector(wall); + if (length === 0) return { nx: 0, ny: 0 }; + // Perpendicular: rotate 90 degrees CCW + return { nx: -dy / length, ny: dx / length }; +} + +/** Opening position on a wall: start and end distances along wall from wall start. */ +export interface OpeningSlice { + readonly opening: WallOpening; + readonly startAlongWall: number; + readonly endAlongWall: number; +} + +/** + * Sort openings on a wall by position and compute their start/end distances. + */ +export function getOpeningSlices( + wall: Wall, + openings: readonly WallOpening[], +): readonly OpeningSlice[] { + const wallOpenings = openings.filter((o) => o.wallId === wall.id); + + return wallOpenings + .map((opening) => ({ + opening, + startAlongWall: opening.positionAlongWall - opening.width / 2, + endAlongWall: opening.positionAlongWall + opening.width / 2, + })) + .sort((a, b) => a.startAlongWall - b.startAlongWall); +} + +/** + * A solid segment of a wall (between openings or at wall ends). + * Heights define the vertical range of this segment. + */ +export interface WallSegment { + readonly startAlongWall: number; + readonly endAlongWall: number; + readonly bottomY: number; + readonly topY: number; +} + +/** + * Split a wall into solid segments around openings. + * Returns segments that should be rendered as solid wall geometry. + * + * For each opening, we create: + * - A segment above the opening (from opening top to wall top) + * - A segment below the opening (from floor to opening bottom) — only if elevationFromFloor > 0 + * - Solid wall to the left and right of the opening + */ +export function splitWallAroundOpenings( + wall: Wall, + openings: readonly WallOpening[], + wallHeight: number, +): readonly WallSegment[] { + const slices = getOpeningSlices(wall, openings); + const { length } = wallVector(wall); + + if (slices.length === 0) { + return [{ startAlongWall: 0, endAlongWall: length, bottomY: 0, topY: wallHeight }]; + } + + const segments: WallSegment[] = []; + + // Full-height segments between openings + let cursor = 0; + for (const slice of slices) { + // Solid wall before this opening + if (slice.startAlongWall > cursor) { + segments.push({ + startAlongWall: cursor, + endAlongWall: slice.startAlongWall, + bottomY: 0, + topY: wallHeight, + }); + } + + // Segment below the opening (sill) + if (slice.opening.elevationFromFloor > 0) { + segments.push({ + startAlongWall: slice.startAlongWall, + endAlongWall: slice.endAlongWall, + bottomY: 0, + topY: slice.opening.elevationFromFloor, + }); + } + + // Segment above the opening (lintel to ceiling) + const openingTop = slice.opening.elevationFromFloor + slice.opening.height; + if (openingTop < wallHeight) { + segments.push({ + startAlongWall: slice.startAlongWall, + endAlongWall: slice.endAlongWall, + bottomY: openingTop, + topY: wallHeight, + }); + } + + cursor = slice.endAlongWall; + } + + // Solid wall after last opening + if (cursor < length) { + segments.push({ + startAlongWall: cursor, + endAlongWall: length, + bottomY: 0, + topY: wallHeight, + }); + } + + return segments; +} + +/** + * Compute 3D position along a wall at a given distance from wall start. + * Returns [x, z] in Three.js floor plane coords. + */ +export function positionAlongWall3D( + wall: Wall, + distanceAlongWall: number, +): [number, number] { + const { dx, dy, length } = wallVector(wall); + if (length === 0) return [wall.startX, wall.startY]; + const t = distanceAlongWall / length; + return [wall.startX + dx * t, wall.startY + dy * t]; +} + +/** + * Compute the center position of a wall segment in 3D space. + */ +export function wallSegmentCenter3D( + wall: Wall, + segment: WallSegment, +): [number, number, number] { + const midAlongWall = (segment.startAlongWall + segment.endAlongWall) / 2; + const [x, z] = positionAlongWall3D(wall, midAlongWall); + const y = (segment.bottomY + segment.topY) / 2; + return [x, y, z]; +} + +/** + * Get the rotation angle of a wall around the Y axis (for Three.js). + * Returns angle in radians. + */ +export function wallRotationY(wall: Wall): number { + const { dx, dy } = wallVector(wall); + return -Math.atan2(dy, dx); +} diff --git a/apps/client/src/components/editor/tools/DoorTool.ts b/apps/client/src/components/editor/tools/DoorTool.ts new file mode 100644 index 0000000..e66e6c5 --- /dev/null +++ b/apps/client/src/components/editor/tools/DoorTool.ts @@ -0,0 +1,77 @@ +import type { Point, Wall, WallOpening } from '@house-plan-maker/shared'; +import { findNearestWall, wallLength } from '../utils/wallUtils'; +import { generateLocalId } from '../utils/geometry'; +import { hasOverlap } from '../utils/openingUtils'; + +/** Maximum distance (in meters) to the nearest wall for door placement. */ +const MAX_WALL_DISTANCE = 0.5; + +/** Default door width in meters. */ +export const DEFAULT_DOOR_WIDTH = 0.9; + +/** Default door height in meters. */ +export const DEFAULT_DOOR_HEIGHT = 2.1; + +export interface DoorPlacementPreview { + readonly wall: Wall; + readonly positionAlongWall: number; + readonly snappedPoint: Point; + readonly width: number; + readonly isValid: boolean; +} + +/** + * Compute a door placement preview from a cursor position. + * Returns null if no wall is close enough. + */ +export function computeDoorPreview( + worldPoint: Point, + walls: readonly Wall[], + existingOpenings: readonly WallOpening[], + width: number = DEFAULT_DOOR_WIDTH, +): DoorPlacementPreview | null { + const nearest = findNearestWall(worldPoint, walls); + if (!nearest || nearest.distance > MAX_WALL_DISTANCE) { + return null; + } + + const wLen = wallLength(nearest.wall); + const halfWidth = width / 2; + + // Clamp position so the door fits within the wall + const clampedPos = Math.max(halfWidth, Math.min(wLen - halfWidth, nearest.positionAlongWall)); + + // Check for overlap with existing openings on the same wall + const isValid = !hasOverlap(nearest.wall.id, clampedPos, width, existingOpenings); + + return { + wall: nearest.wall, + positionAlongWall: clampedPos, + snappedPoint: nearest.projected, + width, + isValid, + }; +} + +/** + * Create a WallOpening object for a door. + */ +export function createDoorOpening( + roomId: string, + wallId: string, + positionAlongWall: number, + width: number = DEFAULT_DOOR_WIDTH, + height: number = DEFAULT_DOOR_HEIGHT, +): WallOpening { + return { + id: generateLocalId(), + roomId, + wallId, + type: 'DOOR', + positionAlongWall, + width, + height, + elevationFromFloor: 0, + openDirection: 'LEFT', + }; +} diff --git a/apps/client/src/components/editor/tools/ElectricalTool.ts b/apps/client/src/components/editor/tools/ElectricalTool.ts new file mode 100644 index 0000000..ae71e69 --- /dev/null +++ b/apps/client/src/components/editor/tools/ElectricalTool.ts @@ -0,0 +1,131 @@ +import type { Point, Wall, ElectricalItem, ElectricalType } from '@house-plan-maker/shared'; +import { findNearestWall, wallAngle } from '../utils/wallUtils'; +import { generateLocalId } from '../utils/geometry'; +import { DEFAULT_ELEVATIONS } from '../utils/projectionMapping'; +import type { ElectricalSymbolDef } from '../symbols/electrical'; + +/** Maximum snap distance to wall (meters). */ +const WALL_SNAP_DISTANCE = 0.5; + +export interface ElectricalPlacementPreview { + readonly x: number; + readonly y: number; + readonly rotation: number; + readonly wallId: string | null; + readonly isValid: boolean; +} + +/** + * Compute electrical item placement preview. + * Wall-mounted items snap to the nearest wall; ceiling items place freely. + */ +export function computeElectricalPreview( + worldPoint: Point, + walls: readonly Wall[], + symbolDef: ElectricalSymbolDef, +): ElectricalPlacementPreview { + if (symbolDef.wallMounted) { + const nearest = findNearestWall(worldPoint, walls); + if (nearest && nearest.distance <= WALL_SNAP_DISTANCE) { + const angle = wallAngle(nearest.wall); + return { + x: nearest.projected.x, + y: nearest.projected.y, + rotation: (angle * 180) / Math.PI, + wallId: nearest.wall.id, + isValid: true, + }; + } + // Too far from any wall + return { + x: worldPoint.x, + y: worldPoint.y, + rotation: 0, + wallId: null, + isValid: false, + }; + } + + // Ceiling/free placement + return { + x: worldPoint.x, + y: worldPoint.y, + rotation: 0, + wallId: null, + isValid: true, + }; +} + +/** + * Resolve default elevation for an electrical item type. + * Wall-mounted types get their standard elevation, ceiling lights get wall height, + * and cable routes remain null. + */ +export function getDefaultElevation( + type: ElectricalType, + wallHeight?: number, +): number | null { + if (type === 'LIGHT_CEILING') { + return wallHeight ?? 2.5; + } + if (type === 'CABLE_ROUTE') { + return null; + } + return DEFAULT_ELEVATIONS[type] ?? null; +} + +/** + * Create an ElectricalItem from placement data. + */ +export function createElectricalItemFromPlacement( + roomId: string, + preview: ElectricalPlacementPreview, + type: ElectricalType, + variant?: string, + wallHeight?: number, +): ElectricalItem { + const metadata: Record | null = variant ? { variant } : null; + + return { + id: generateLocalId(), + roomId, + type, + x: preview.x, + y: preview.y, + wallId: preview.wallId, + elevationFromFloor: getDefaultElevation(type, wallHeight), + rotation: preview.rotation, + metadata, + }; +} + +/** + * Compute total cable route length from a list of electrical items of type CABLE_ROUTE. + * Each cable route stores waypoints in metadata.points as Point[]. + * Returns length in meters. + */ +export function computeCableRouteLength(electricalItems: readonly ElectricalItem[]): number { + let total = 0; + + for (const item of electricalItems) { + if (item.type !== 'CABLE_ROUTE') continue; + const points = getCableWaypoints(item); + if (points.length < 2) continue; + + for (let i = 1; i < points.length; i++) { + const dx = points[i].x - points[i - 1].x; + const dy = points[i].y - points[i - 1].y; + total += Math.sqrt(dx * dx + dy * dy); + } + } + + return total; +} + +/** Extract cable waypoints from metadata. */ +export function getCableWaypoints(item: ElectricalItem): readonly Point[] { + if (!item.metadata || !Array.isArray(item.metadata['points'])) { + return [{ x: item.x, y: item.y }]; + } + return item.metadata['points'] as Point[]; +} diff --git a/apps/client/src/components/editor/tools/FurnitureTool.ts b/apps/client/src/components/editor/tools/FurnitureTool.ts new file mode 100644 index 0000000..2fd5e3f --- /dev/null +++ b/apps/client/src/components/editor/tools/FurnitureTool.ts @@ -0,0 +1,73 @@ +import type { Point, FurnitureItem } from '@house-plan-maker/shared'; +import { generateLocalId } from '../utils/geometry'; +import type { FurnitureDef } from '../symbols/furniture'; + +export interface FurniturePlacementPreview { + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly rotation: number; + readonly isValid: boolean; +} + +/** + * Compute furniture placement preview. + * The x,y represents the top-left corner of the furniture piece. + * The cursor world point is treated as the desired center, so we offset + * by half-width and half-depth to get the top-left corner. + */ +export function computeFurniturePreview( + worldPoint: Point, + furnitureDef: FurnitureDef, + rotation: number = 0, +): FurniturePlacementPreview { + return { + x: worldPoint.x - furnitureDef.width / 2, + y: worldPoint.y - furnitureDef.depth / 2, + width: furnitureDef.width, + depth: furnitureDef.depth, + rotation, + isValid: true, + }; +} + +/** + * Create a FurnitureItem from placement data. + */ +export function createFurnitureItemFromPlacement( + roomId: string, + preview: FurniturePlacementPreview, + furnitureDef: FurnitureDef, +): FurnitureItem { + return { + id: generateLocalId(), + roomId, + type: furnitureDef.type, + x: preview.x, + y: preview.y, + width: preview.width, + depth: preview.depth, + height: furnitureDef.height, + rotation: preview.rotation, + elevationFromFloor: furnitureDef.type === 'AC_UNIT' ? 2.2 : furnitureDef.height <= 0.05 ? 1.2 : 0, + label: furnitureDef.label, + }; +} + +/** Rotate a furniture item by the given delta in degrees. */ +export function rotateFurniture(item: FurnitureItem, deltaDeg: number): FurnitureItem { + return { + ...item, + rotation: (item.rotation + deltaDeg) % 360, + }; +} + +/** Move a furniture item to a new position. */ +export function moveFurniture(item: FurnitureItem, newPos: Point): FurnitureItem { + return { + ...item, + x: newPos.x, + y: newPos.y, + }; +} diff --git a/apps/client/src/components/editor/tools/MeasureTool.ts b/apps/client/src/components/editor/tools/MeasureTool.ts new file mode 100644 index 0000000..1888e4e --- /dev/null +++ b/apps/client/src/components/editor/tools/MeasureTool.ts @@ -0,0 +1,33 @@ +import type { Point } from '@house-plan-maker/shared'; +import type { MeasurementState, MeasurementResult } from '../types'; +import { distance } from '../utils/geometry'; + +/** Start a new measurement from the given point. */ +export function startMeasurement(point: Point): MeasurementState { + return { + startPoint: point, + endPoint: point, + distance: 0, + }; +} + +/** Update the measurement end point and recalculate distance. */ +export function updateMeasurement( + state: MeasurementState, + point: Point, +): MeasurementState { + return { + ...state, + endPoint: point, + distance: distance(state.startPoint, point), + }; +} + +/** Finalize the measurement and return the result. */ +export function finishMeasurement(state: MeasurementState): MeasurementResult { + return { + startPoint: state.startPoint, + endPoint: state.endPoint, + distance: distance(state.startPoint, state.endPoint), + }; +} diff --git a/apps/client/src/components/editor/tools/SelectTool.ts b/apps/client/src/components/editor/tools/SelectTool.ts new file mode 100644 index 0000000..30a406c --- /dev/null +++ b/apps/client/src/components/editor/tools/SelectTool.ts @@ -0,0 +1,240 @@ +import type { Point, Wall, WallOpening, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared'; +import type { DragState } from '../types'; +import { distance } from '../utils/geometry'; +import { findNearestWall } from '../utils/wallUtils'; + +/** Hit-test radius in meters for selecting elements. */ +const HIT_RADIUS = 0.15; + +export interface SelectToolState { + readonly drag: DragState | null; +} + +export function createInitialSelectState(): SelectToolState { + return { drag: null }; +} + +/** + * Find which element (if any) was clicked. + * Returns the element ID and type, or null. + */ +export function hitTest( + worldPoint: Point, + openings: readonly WallOpening[], + walls: readonly Wall[], + electricalItems: readonly ElectricalItem[], + furnitureItems: readonly FurnitureItem[], +): { id: string; type: 'opening' | 'wall' | 'electrical' | 'furniture' } | null { + // Check openings first (they're on walls) + for (const opening of openings) { + const wall = walls.find((w) => w.id === opening.wallId); + if (!wall) continue; + + const wallLen = distance( + { x: wall.startX, y: wall.startY }, + { x: wall.endX, y: wall.endY }, + ); + if (wallLen === 0) continue; + + const dx = (wall.endX - wall.startX) / wallLen; + const dy = (wall.endY - wall.startY) / wallLen; + + const centerX = wall.startX + dx * opening.positionAlongWall; + const centerY = wall.startY + dy * opening.positionAlongWall; + + const dist = distance(worldPoint, { x: centerX, y: centerY }); + if (dist < Math.max(opening.width / 2, HIT_RADIUS)) { + return { id: opening.id, type: 'opening' }; + } + } + + // Check electrical items + for (const item of electricalItems) { + const dist = distance(worldPoint, { x: item.x, y: item.y }); + if (dist < HIT_RADIUS) { + return { id: item.id, type: 'electrical' }; + } + } + + // Check furniture items (rotation-aware: transform point into item's local space) + for (const item of furnitureItems) { + const cx = item.x + item.width / 2; + const cy = item.y + item.depth / 2; + // Translate point relative to item center + const dx = worldPoint.x - cx; + const dy = worldPoint.y - cy; + // Rotate point by -rotation to get local coordinates + const rad = -(item.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const localX = dx * cos - dy * sin; + const localY = dx * sin + dy * cos; + // Check if local point is within the unrotated rectangle + if ( + Math.abs(localX) <= item.width / 2 && + Math.abs(localY) <= item.depth / 2 + ) { + return { id: item.id, type: 'furniture' }; + } + } + + // Check walls + const nearestWall = findNearestWall(worldPoint, walls); + if (nearestWall && nearestWall.distance < Math.max(nearestWall.wall.thickness, HIT_RADIUS)) { + return { id: nearestWall.wall.id, type: 'wall' }; + } + + return null; +} + +/** + * Compute the selection rectangle from drag start to current position. + * Returns { x, y, width, height } in world coordinates, normalized so width/height are positive. + */ +export function selectionRect( + start: Point, + current: Point, +): { x: number; y: number; width: number; height: number } { + const x = Math.min(start.x, current.x); + const y = Math.min(start.y, current.y); + const width = Math.abs(current.x - start.x); + const height = Math.abs(current.y - start.y); + return { x, y, width, height }; +} + +/** + * Find all element IDs inside a selection rectangle. + */ +export function elementsInRect( + rect: { x: number; y: number; width: number; height: number }, + openings: readonly WallOpening[], + walls: readonly Wall[], + electricalItems: readonly ElectricalItem[], + furnitureItems: readonly FurnitureItem[], +): ReadonlySet { + const ids = new Set(); + + for (const opening of openings) { + const wall = walls.find((w) => w.id === opening.wallId); + if (!wall) continue; + + const wallLen = distance( + { x: wall.startX, y: wall.startY }, + { x: wall.endX, y: wall.endY }, + ); + if (wallLen === 0) continue; + + const dx = (wall.endX - wall.startX) / wallLen; + const dy = (wall.endY - wall.startY) / wallLen; + const cx = wall.startX + dx * opening.positionAlongWall; + const cy = wall.startY + dy * opening.positionAlongWall; + + if ( + cx >= rect.x && + cx <= rect.x + rect.width && + cy >= rect.y && + cy <= rect.y + rect.height + ) { + ids.add(opening.id); + } + } + + for (const item of electricalItems) { + if ( + item.x >= rect.x && + item.x <= rect.x + rect.width && + item.y >= rect.y && + item.y <= rect.y + rect.height + ) { + ids.add(item.id); + } + } + + for (const item of furnitureItems) { + // x,y is top-left; use center point for selection-rect containment + const cx = item.x + item.width / 2; + const cy = item.y + item.depth / 2; + if ( + cx >= rect.x && + cx <= rect.x + rect.width && + cy >= rect.y && + cy <= rect.y + rect.height + ) { + ids.add(item.id); + } + } + + return ids; +} + +/** + * Compute the bounding box of all selected elements (for resize handles). + */ +export function selectedBoundingBox( + selectedIds: ReadonlySet, + openings: readonly WallOpening[], + walls: readonly Wall[], + electricalItems: readonly ElectricalItem[], + furnitureItems: readonly FurnitureItem[], +): { x: number; y: number; width: number; height: number } | null { + const points: Point[] = []; + + for (const id of selectedIds) { + const opening = openings.find((o) => o.id === id); + if (opening) { + const wall = walls.find((w) => w.id === opening.wallId); + if (wall) { + const wallLen = distance( + { x: wall.startX, y: wall.startY }, + { x: wall.endX, y: wall.endY }, + ); + if (wallLen > 0) { + const dx = (wall.endX - wall.startX) / wallLen; + const dy = (wall.endY - wall.startY) / wallLen; + const cx = wall.startX + dx * opening.positionAlongWall; + const cy = wall.startY + dy * opening.positionAlongWall; + points.push({ x: cx - opening.width / 2, y: cy - opening.width / 2 }); + points.push({ x: cx + opening.width / 2, y: cy + opening.width / 2 }); + } + } + continue; + } + + const elec = electricalItems.find((e) => e.id === id); + if (elec) { + points.push({ x: elec.x - 0.05, y: elec.y - 0.05 }); + points.push({ x: elec.x + 0.05, y: elec.y + 0.05 }); + continue; + } + + const furn = furnitureItems.find((f) => f.id === id); + if (furn) { + // Compute rotated AABB from center + rotation + const cx = furn.x + furn.width / 2; + const cy = furn.y + furn.depth / 2; + const rad = (furn.rotation * Math.PI) / 180; + const cos = Math.abs(Math.cos(rad)); + const sin = Math.abs(Math.sin(rad)); + const halfW = (furn.width * cos + furn.depth * sin) / 2; + const halfD = (furn.width * sin + furn.depth * cos) / 2; + points.push({ x: cx - halfW, y: cy - halfD }); + points.push({ x: cx + halfW, y: cy + halfD }); + } + } + + if (points.length === 0) return null; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const p of points) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} diff --git a/apps/client/src/components/editor/tools/WindowTool.ts b/apps/client/src/components/editor/tools/WindowTool.ts new file mode 100644 index 0000000..49a1e53 --- /dev/null +++ b/apps/client/src/components/editor/tools/WindowTool.ts @@ -0,0 +1,81 @@ +import type { Point, Wall, WallOpening } from '@house-plan-maker/shared'; +import { findNearestWall, wallLength } from '../utils/wallUtils'; +import { generateLocalId } from '../utils/geometry'; +import { hasOverlap } from '../utils/openingUtils'; + +/** Maximum distance (in meters) to the nearest wall for window placement. */ +const MAX_WALL_DISTANCE = 0.5; + +/** Default window width in meters. */ +export const DEFAULT_WINDOW_WIDTH = 1.2; + +/** Default window height in meters. */ +export const DEFAULT_WINDOW_HEIGHT = 1.4; + +/** Default window elevation from floor in meters. */ +export const DEFAULT_WINDOW_ELEVATION = 0.9; + +export interface WindowPlacementPreview { + readonly wall: Wall; + readonly positionAlongWall: number; + readonly snappedPoint: Point; + readonly width: number; + readonly isValid: boolean; +} + +/** + * Compute a window placement preview from a cursor position. + * Returns null if no wall is close enough. + */ +export function computeWindowPreview( + worldPoint: Point, + walls: readonly Wall[], + existingOpenings: readonly WallOpening[], + width: number = DEFAULT_WINDOW_WIDTH, +): WindowPlacementPreview | null { + const nearest = findNearestWall(worldPoint, walls); + if (!nearest || nearest.distance > MAX_WALL_DISTANCE) { + return null; + } + + const wLen = wallLength(nearest.wall); + const halfWidth = width / 2; + + // Clamp position so the window fits within the wall + const clampedPos = Math.max(halfWidth, Math.min(wLen - halfWidth, nearest.positionAlongWall)); + + // Check for overlap with existing openings on the same wall + const isValid = !hasOverlap(nearest.wall.id, clampedPos, width, existingOpenings); + + return { + wall: nearest.wall, + positionAlongWall: clampedPos, + snappedPoint: nearest.projected, + width, + isValid, + }; +} + +/** + * Create a WallOpening object for a window. + */ +export function createWindowOpening( + roomId: string, + wallId: string, + positionAlongWall: number, + width: number = DEFAULT_WINDOW_WIDTH, + height: number = DEFAULT_WINDOW_HEIGHT, + elevation: number = DEFAULT_WINDOW_ELEVATION, +): WallOpening { + return { + id: generateLocalId(), + roomId, + wallId, + type: 'WINDOW', + positionAlongWall, + width, + height, + elevationFromFloor: elevation, + openDirection: 'LEFT', + }; +} diff --git a/apps/client/src/components/editor/types.ts b/apps/client/src/components/editor/types.ts new file mode 100644 index 0000000..7c567f7 --- /dev/null +++ b/apps/client/src/components/editor/types.ts @@ -0,0 +1,186 @@ +import type { Point, Wall, WallOpening, ElectricalItem, FurnitureItem, RoomFull, Annotation } from '@house-plan-maker/shared'; + +// ── Tool Types ── + +export type EditorToolType = 'select' | 'door' | 'window' | 'electrical' | 'furniture' | 'measure' | 'annotate'; + +export interface CanvasPointerEvent { + /** Position in world/meter coordinates. */ + readonly worldPoint: Point; + /** Position in screen/pixel coordinates. */ + readonly screenPoint: Point; + /** Whether shift key is held. */ + readonly shiftKey: boolean; + /** Whether ctrl/cmd key is held. */ + readonly ctrlKey: boolean; + /** The original Konva event (if available). */ + readonly originalEvent?: MouseEvent; +} + +// ── Selection ── + +export type SelectableElementType = 'wall' | 'opening' | 'electrical' | 'furniture'; + +export interface SelectableElement { + readonly id: string; + readonly type: SelectableElementType; +} + +// ── Editor State ── + +/** Layer visibility toggles. */ +export interface LayerVisibility { + readonly walls: boolean; + readonly electrical: boolean; + readonly furniture: boolean; + readonly measurements: boolean; + readonly annotations: boolean; +} + +export interface EditorState { + readonly room: RoomFull; + readonly walls: readonly Wall[]; + readonly openings: readonly WallOpening[]; + readonly electricalItems: readonly ElectricalItem[]; + readonly furnitureItems: readonly FurnitureItem[]; + readonly selectedIds: ReadonlySet; + readonly activeTool: EditorToolType; + readonly zoom: number; + readonly panOffset: Point; + readonly gridSize: number; + readonly gridVisible: boolean; + readonly snapEnabled: boolean; + readonly snapGranularity: number; + readonly layerVisibility: LayerVisibility; + /** Index into ELECTRICAL_SYMBOL_DEFS for electrical tool. */ + readonly selectedElectricalIndex: number | null; + /** Index into FURNITURE_DEFS for furniture tool. */ + readonly selectedFurnitureIndex: number | null; + readonly annotations: readonly Annotation[]; +} + +// ── Undo/Redo Commands ── + +export interface EditorCommand { + readonly description: string; + execute(): void; + undo(): void; +} + +// ── Editor Actions ── + +export type EditorAction = + | { readonly type: 'SET_ROOM'; readonly room: RoomFull } + | { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial> } + | { readonly type: 'SET_WALLS'; readonly walls: readonly Wall[] } + | { readonly type: 'UPDATE_WALL'; readonly wall: Wall } + | { readonly type: 'ADD_OPENING'; readonly opening: WallOpening } + | { readonly type: 'UPDATE_OPENING'; readonly opening: WallOpening } + | { readonly type: 'REMOVE_OPENING'; readonly id: string } + | { readonly type: 'ADD_ELECTRICAL'; readonly item: ElectricalItem } + | { readonly type: 'UPDATE_ELECTRICAL'; readonly item: ElectricalItem } + | { readonly type: 'REMOVE_ELECTRICAL'; readonly id: string } + | { readonly type: 'ADD_FURNITURE'; readonly item: FurnitureItem } + | { readonly type: 'UPDATE_FURNITURE'; readonly item: FurnitureItem } + | { readonly type: 'REMOVE_FURNITURE'; readonly id: string } + | { readonly type: 'SET_SELECTED'; readonly ids: ReadonlySet } + | { readonly type: 'ADD_TO_SELECTION'; readonly id: string } + | { readonly type: 'REMOVE_FROM_SELECTION'; readonly id: string } + | { readonly type: 'CLEAR_SELECTION' } + | { readonly type: 'SET_TOOL'; readonly tool: EditorToolType } + | { readonly type: 'SET_ZOOM'; readonly zoom: number } + | { readonly type: 'SET_PAN_OFFSET'; readonly offset: Point } + | { readonly type: 'SET_GRID_SIZE'; readonly gridSize: number } + | { readonly type: 'TOGGLE_GRID' } + | { readonly type: 'TOGGLE_SNAP' } + | { readonly type: 'SET_SNAP_GRANULARITY'; readonly granularity: number } + | { readonly type: 'TOGGLE_LAYER'; readonly layer: keyof LayerVisibility } + | { readonly type: 'SET_ELECTRICAL_INDEX'; readonly index: number | null } + | { readonly type: 'SET_FURNITURE_INDEX'; readonly index: number | null } + | { readonly type: 'DELETE_SELECTED' } + | { readonly type: 'SELECT_ALL' } + // Clipboard + | { readonly type: 'COPY_SELECTED' } + | { readonly type: 'PASTE_CLIPBOARD'; readonly items: PastePayload } + // Alignment + | { readonly type: 'ALIGN_SELECTED'; readonly alignment: AlignmentDirection } + // Annotations + | { readonly type: 'ADD_ANNOTATION'; readonly annotation: Annotation } + | { readonly type: 'UPDATE_ANNOTATION'; readonly annotation: Annotation } + | { readonly type: 'REMOVE_ANNOTATION'; readonly id: string } + // Import + | { + readonly type: 'IMPORT_ROOM'; + readonly room: { + readonly name: string; + readonly shape: readonly Point[]; + readonly wallHeight: number; + readonly plinthHeight: number; + readonly plinthThickness: number; + }; + readonly walls: readonly Wall[]; + readonly openings: readonly WallOpening[]; + readonly electricalItems: readonly ElectricalItem[]; + readonly furnitureItems: readonly FurnitureItem[]; + } + // Sync after save (update server IDs without full reload) + | { + readonly type: 'SYNC_SAVE'; + readonly walls: readonly Wall[]; + readonly openings: readonly WallOpening[]; + readonly electricalItems: readonly ElectricalItem[]; + readonly furnitureItems: readonly FurnitureItem[]; + }; + +// ── Alignment ── + +export type AlignmentDirection = + | 'left' + | 'right' + | 'top' + | 'bottom' + | 'center-h' + | 'center-v' + | 'distribute-h' + | 'distribute-v'; + +// ── Clipboard ── + +export interface PastePayload { + readonly openings: readonly WallOpening[]; + readonly electricalItems: readonly ElectricalItem[]; + readonly furnitureItems: readonly FurnitureItem[]; + readonly annotations: readonly Annotation[]; + readonly newSelectedIds: ReadonlySet; +} + +// ── Zoom/Pan Config ── + +export const MIN_ZOOM = 10; // 10 pixels per meter (very zoomed out) +export const MAX_ZOOM = 500; // 500 pixels per meter (very zoomed in) +export const DEFAULT_ZOOM = 100; // 100 pixels per meter +export const ZOOM_SENSITIVITY = 0.001; +export const DEFAULT_GRID_SIZE = 0.1; // 0.1 meters = 10cm + +// ── Measurement State ── + +export interface MeasurementState { + readonly startPoint: Point; + readonly endPoint: Point; + readonly distance: number; +} + +export interface MeasurementResult { + readonly startPoint: Point; + readonly endPoint: Point; + readonly distance: number; +} + +// ── Drag State (for selection tool) ── + +export interface DragState { + readonly isDragging: boolean; + readonly startWorld: Point; + readonly currentWorld: Point; + readonly dragType: 'move' | 'select-rect'; +} diff --git a/apps/client/src/components/editor/utils/__tests__/collisionDetection.test.ts b/apps/client/src/components/editor/utils/__tests__/collisionDetection.test.ts new file mode 100644 index 0000000..0e7763e --- /dev/null +++ b/apps/client/src/components/editor/utils/__tests__/collisionDetection.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import type { FurnitureItem } from '@house-plan-maker/shared'; +import { findCollidingFurniture } from '../collisionDetection'; + +function makeFurniture(overrides: Partial = {}): FurnitureItem { + return { + id: 'f1', + roomId: 'r1', + type: 'TABLE', + x: 0, + y: 0, + width: 1, + depth: 1, + height: 0.75, + rotation: 0, + elevationFromFloor: 0, + label: null, + ...overrides, + }; +} + +describe('findCollidingFurniture', () => { + it('returns empty set for fewer than 2 items', () => { + expect(findCollidingFurniture([])).toEqual(new Set()); + expect(findCollidingFurniture([makeFurniture()])).toEqual(new Set()); + }); + + it('detects overlapping furniture', () => { + const items: readonly FurnitureItem[] = [ + makeFurniture({ id: 'f1', x: 0, y: 0, width: 2, depth: 2 }), + makeFurniture({ id: 'f2', x: 1, y: 1, width: 2, depth: 2 }), + ]; + const result = findCollidingFurniture(items); + expect(result.has('f1')).toBe(true); + expect(result.has('f2')).toBe(true); + }); + + it('returns empty set for non-overlapping furniture', () => { + const items: readonly FurnitureItem[] = [ + makeFurniture({ id: 'f1', x: 0, y: 0, width: 1, depth: 1 }), + makeFurniture({ id: 'f2', x: 5, y: 5, width: 1, depth: 1 }), + ]; + const result = findCollidingFurniture(items); + expect(result.size).toBe(0); + }); + + it('handles rotated furniture (AABB check)', () => { + // A 2x1 table rotated 90 degrees becomes 1x2 AABB + const items: readonly FurnitureItem[] = [ + makeFurniture({ id: 'f1', x: 0, y: 0, width: 2, depth: 0.5, rotation: 0 }), + makeFurniture({ id: 'f2', x: 0, y: 0.5, width: 2, depth: 0.5, rotation: 0 }), + ]; + const result = findCollidingFurniture(items); + // These should be touching at edge, potentially overlapping depending on centers + // f1 center at (0,0), AABB y range: -0.25 to 0.25 + // f2 center at (0,0.5), AABB y range: 0.25 to 0.75 + // They touch at y=0.25 but don't overlap (strict inequality) + expect(result.size).toBe(0); + }); + + it('detects multiple collisions', () => { + const items: readonly FurnitureItem[] = [ + makeFurniture({ id: 'f1', x: 0, y: 0, width: 2, depth: 2 }), + makeFurniture({ id: 'f2', x: 0.5, y: 0.5, width: 2, depth: 2 }), + makeFurniture({ id: 'f3', x: 10, y: 10, width: 1, depth: 1 }), + ]; + const result = findCollidingFurniture(items); + expect(result.has('f1')).toBe(true); + expect(result.has('f2')).toBe(true); + expect(result.has('f3')).toBe(false); + }); +}); diff --git a/apps/client/src/components/editor/utils/__tests__/geometry.test.ts b/apps/client/src/components/editor/utils/__tests__/geometry.test.ts new file mode 100644 index 0000000..d51cf72 --- /dev/null +++ b/apps/client/src/components/editor/utils/__tests__/geometry.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from 'vitest'; +import { + distance, + snapToGrid, + snapPointToGrid, + clamp, + projectPointOntoSegment, + segmentAngle, + pointInPolygon, + boundingBox, + rectsOverlap, + generateLocalId, +} from '../geometry'; + +describe('distance', () => { + it('returns 0 for same point', () => { + expect(distance({ x: 1, y: 2 }, { x: 1, y: 2 })).toBe(0); + }); + + it('computes horizontal distance', () => { + expect(distance({ x: 0, y: 0 }, { x: 3, y: 0 })).toBe(3); + }); + + it('computes vertical distance', () => { + expect(distance({ x: 0, y: 0 }, { x: 0, y: 4 })).toBe(4); + }); + + it('computes diagonal distance (3-4-5 triangle)', () => { + expect(distance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5); + }); + + it('works with negative coordinates', () => { + expect(distance({ x: -1, y: -1 }, { x: 2, y: 3 })).toBe(5); + }); +}); + +describe('snapToGrid', () => { + it('snaps to nearest multiple', () => { + expect(snapToGrid(0.14, 0.1)).toBeCloseTo(0.1); + }); + + it('snaps exactly on grid', () => { + expect(snapToGrid(0.2, 0.1)).toBeCloseTo(0.2); + }); + + it('rounds up when closer to next grid', () => { + expect(snapToGrid(0.16, 0.1)).toBeCloseTo(0.2); + }); + + it('snaps to 0 for values near 0', () => { + expect(snapToGrid(0.04, 0.1)).toBeCloseTo(0); + }); +}); + +describe('snapPointToGrid', () => { + it('snaps both x and y', () => { + const result = snapPointToGrid({ x: 0.14, y: 0.27 }, 0.1); + expect(result.x).toBeCloseTo(0.1); + expect(result.y).toBeCloseTo(0.3); + }); +}); + +describe('clamp', () => { + it('returns value when in range', () => { + expect(clamp(5, 0, 10)).toBe(5); + }); + + it('clamps to min', () => { + expect(clamp(-1, 0, 10)).toBe(0); + }); + + it('clamps to max', () => { + expect(clamp(15, 0, 10)).toBe(10); + }); + + it('handles min === max', () => { + expect(clamp(5, 3, 3)).toBe(3); + }); +}); + +describe('projectPointOntoSegment', () => { + it('projects onto midpoint of horizontal segment', () => { + const result = projectPointOntoSegment( + { x: 5, y: 3 }, + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ); + expect(result.projected.x).toBeCloseTo(5); + expect(result.projected.y).toBeCloseTo(0); + expect(result.t).toBeCloseTo(0.5); + expect(result.distance).toBeCloseTo(3); + }); + + it('clamps to start of segment', () => { + const result = projectPointOntoSegment( + { x: -5, y: 0 }, + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ); + expect(result.projected.x).toBeCloseTo(0); + expect(result.t).toBeCloseTo(0); + }); + + it('clamps to end of segment', () => { + const result = projectPointOntoSegment( + { x: 15, y: 0 }, + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ); + expect(result.projected.x).toBeCloseTo(10); + expect(result.t).toBeCloseTo(1); + }); + + it('handles zero-length segment', () => { + const result = projectPointOntoSegment( + { x: 3, y: 4 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ); + expect(result.t).toBe(0); + expect(result.distance).toBe(5); + }); +}); + +describe('segmentAngle', () => { + it('returns 0 for rightward segment', () => { + expect(segmentAngle({ x: 0, y: 0 }, { x: 1, y: 0 })).toBeCloseTo(0); + }); + + it('returns PI/2 for downward segment', () => { + expect(segmentAngle({ x: 0, y: 0 }, { x: 0, y: 1 })).toBeCloseTo(Math.PI / 2); + }); + + it('returns PI for leftward segment', () => { + expect(segmentAngle({ x: 0, y: 0 }, { x: -1, y: 0 })).toBeCloseTo(Math.PI); + }); + + it('returns -PI/2 for upward segment', () => { + expect(segmentAngle({ x: 0, y: 0 }, { x: 0, y: -1 })).toBeCloseTo(-Math.PI / 2); + }); +}); + +describe('pointInPolygon', () => { + const square = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, + ]; + + it('returns true for point inside', () => { + expect(pointInPolygon({ x: 5, y: 5 }, square)).toBe(true); + }); + + it('returns false for point outside', () => { + expect(pointInPolygon({ x: 15, y: 5 }, square)).toBe(false); + }); + + it('returns false for point far outside', () => { + expect(pointInPolygon({ x: -5, y: -5 }, square)).toBe(false); + }); + + it('handles triangle', () => { + const triangle = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 5, y: 10 }, + ]; + expect(pointInPolygon({ x: 5, y: 3 }, triangle)).toBe(true); + expect(pointInPolygon({ x: 9, y: 9 }, triangle)).toBe(false); + }); +}); + +describe('boundingBox', () => { + it('returns zero-box for empty array', () => { + const bb = boundingBox([]); + expect(bb).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 }); + }); + + it('returns point for single point', () => { + const bb = boundingBox([{ x: 3, y: 5 }]); + expect(bb).toEqual({ minX: 3, minY: 5, maxX: 3, maxY: 5 }); + }); + + it('computes correct bounding box', () => { + const bb = boundingBox([ + { x: -1, y: 2 }, + { x: 5, y: -3 }, + { x: 3, y: 7 }, + ]); + expect(bb).toEqual({ minX: -1, minY: -3, maxX: 5, maxY: 7 }); + }); +}); + +describe('rectsOverlap', () => { + it('returns true for overlapping rects', () => { + expect( + rectsOverlap( + { x: 0, y: 0, width: 5, height: 5 }, + { x: 3, y: 3, width: 5, height: 5 }, + ), + ).toBe(true); + }); + + it('returns false for non-overlapping rects', () => { + expect( + rectsOverlap( + { x: 0, y: 0, width: 5, height: 5 }, + { x: 10, y: 10, width: 5, height: 5 }, + ), + ).toBe(false); + }); + + it('returns false for adjacent rects (touching edges)', () => { + expect( + rectsOverlap( + { x: 0, y: 0, width: 5, height: 5 }, + { x: 5, y: 0, width: 5, height: 5 }, + ), + ).toBe(false); + }); + + it('returns true for contained rect', () => { + expect( + rectsOverlap( + { x: 0, y: 0, width: 10, height: 10 }, + { x: 2, y: 2, width: 3, height: 3 }, + ), + ).toBe(true); + }); +}); + +describe('generateLocalId', () => { + it('starts with "local-"', () => { + expect(generateLocalId()).toMatch(/^local-/); + }); + + it('generates unique ids', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateLocalId())); + expect(ids.size).toBe(100); + }); +}); diff --git a/apps/client/src/components/editor/utils/__tests__/wallUtils.test.ts b/apps/client/src/components/editor/utils/__tests__/wallUtils.test.ts new file mode 100644 index 0000000..2dc94ef --- /dev/null +++ b/apps/client/src/components/editor/utils/__tests__/wallUtils.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from 'vitest'; +import type { Wall, WallOpening } from '@house-plan-maker/shared'; +import { + wallStartEnd, + wallLength, + wallAngle, + findNearestWall, + openingWorldPosition, + wallsFromShape, + wallOutlinePolygon, +} from '../wallUtils'; + +function makeWall(overrides: Partial = {}): Wall { + return { + id: 'w1', + roomId: 'r1', + startX: 0, + startY: 0, + endX: 4, + endY: 0, + thickness: 0.1, + direction: 'NORTH' as const, + ...overrides, + }; +} + +describe('wallStartEnd', () => { + it('returns start and end points', () => { + const wall = makeWall({ startX: 1, startY: 2, endX: 3, endY: 4 }); + const result = wallStartEnd(wall); + expect(result.start).toEqual({ x: 1, y: 2 }); + expect(result.end).toEqual({ x: 3, y: 4 }); + }); +}); + +describe('wallLength', () => { + it('computes length of horizontal wall', () => { + expect(wallLength(makeWall())).toBe(4); + }); + + it('computes length of vertical wall', () => { + expect(wallLength(makeWall({ startX: 0, startY: 0, endX: 0, endY: 3 }))).toBe(3); + }); + + it('computes length of diagonal wall', () => { + expect(wallLength(makeWall({ startX: 0, startY: 0, endX: 3, endY: 4 }))).toBe(5); + }); + + it('returns 0 for zero-length wall', () => { + expect(wallLength(makeWall({ startX: 2, startY: 3, endX: 2, endY: 3 }))).toBe(0); + }); +}); + +describe('wallAngle', () => { + it('returns 0 for rightward wall', () => { + expect(wallAngle(makeWall())).toBeCloseTo(0); + }); + + it('returns PI/2 for downward wall', () => { + expect(wallAngle(makeWall({ endX: 0, endY: 5 }))).toBeCloseTo(Math.PI / 2); + }); +}); + +describe('findNearestWall', () => { + it('returns null for no walls', () => { + expect(findNearestWall({ x: 0, y: 0 }, [])).toBeNull(); + }); + + it('finds nearest wall', () => { + const walls: readonly Wall[] = [ + makeWall({ id: 'w1', startX: 0, startY: 0, endX: 4, endY: 0 }), + makeWall({ id: 'w2', startX: 0, startY: 3, endX: 4, endY: 3 }), + ]; + const result = findNearestWall({ x: 2, y: 0.5 }, walls); + expect(result).not.toBeNull(); + expect(result!.wall.id).toBe('w1'); + expect(result!.distance).toBeCloseTo(0.5); + }); + + it('returns position along wall', () => { + const walls: readonly Wall[] = [ + makeWall({ startX: 0, startY: 0, endX: 10, endY: 0 }), + ]; + const result = findNearestWall({ x: 5, y: 1 }, walls); + expect(result).not.toBeNull(); + expect(result!.positionAlongWall).toBeCloseTo(5); + }); +}); + +describe('openingWorldPosition', () => { + it('computes center for opening on horizontal wall', () => { + const wall = makeWall({ startX: 0, startY: 0, endX: 10, endY: 0 }); + const opening: WallOpening = { + id: 'o1', + roomId: 'r1', + wallId: 'w1', + type: 'DOOR', + positionAlongWall: 5, + width: 1, + height: 2.1, + elevationFromFloor: 0, + openDirection: 'LEFT', + }; + const result = openingWorldPosition(opening, wall); + expect(result.center.x).toBeCloseTo(5); + expect(result.center.y).toBeCloseTo(0); + expect(result.start.x).toBeCloseTo(4.5); + expect(result.end.x).toBeCloseTo(5.5); + }); + + it('handles zero-length wall', () => { + const wall = makeWall({ startX: 3, startY: 3, endX: 3, endY: 3 }); + const opening: WallOpening = { + id: 'o1', + roomId: 'r1', + wallId: 'w1', + type: 'WINDOW', + positionAlongWall: 0, + width: 1, + height: 1.2, + elevationFromFloor: 0.9, + openDirection: 'LEFT', + }; + const result = openingWorldPosition(opening, wall); + expect(result.center).toEqual({ x: 3, y: 3 }); + expect(result.angle).toBe(0); + }); +}); + +describe('wallsFromShape', () => { + it('returns empty for fewer than 2 points', () => { + expect(wallsFromShape([])).toEqual([]); + expect(wallsFromShape([{ x: 0, y: 0 }])).toEqual([]); + }); + + it('creates walls from rectangular shape', () => { + const shape = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, + ]; + const walls = wallsFromShape(shape, 0.1); + expect(walls).toHaveLength(4); + + // First wall: top + expect(walls[0]).toEqual({ startX: 0, startY: 0, endX: 4, endY: 0, thickness: 0.1 }); + // Last wall: left, closing the loop + expect(walls[3]).toEqual({ startX: 0, startY: 3, endX: 0, endY: 0, thickness: 0.1 }); + }); + + it('uses default thickness', () => { + const walls = wallsFromShape([{ x: 0, y: 0 }, { x: 1, y: 0 }]); + expect(walls[0].thickness).toBe(0.1); + }); +}); + +describe('wallOutlinePolygon', () => { + it('returns 4 corner points for horizontal wall', () => { + const wall = makeWall({ startX: 0, startY: 0, endX: 4, endY: 0, thickness: 0.2 }); + const poly = wallOutlinePolygon(wall); + expect(poly).toHaveLength(4); + + // For a horizontal wall, perpendicular offset is in Y direction + expect(poly[0].x).toBeCloseTo(0); + expect(poly[0].y).toBeCloseTo(0.1); // +halfThick + expect(poly[1].x).toBeCloseTo(4); + expect(poly[1].y).toBeCloseTo(0.1); + expect(poly[2].x).toBeCloseTo(4); + expect(poly[2].y).toBeCloseTo(-0.1); // -halfThick + expect(poly[3].x).toBeCloseTo(0); + expect(poly[3].y).toBeCloseTo(-0.1); + }); +}); diff --git a/apps/client/src/components/editor/utils/collisionDetection.ts b/apps/client/src/components/editor/utils/collisionDetection.ts new file mode 100644 index 0000000..176e94e --- /dev/null +++ b/apps/client/src/components/editor/utils/collisionDetection.ts @@ -0,0 +1,105 @@ +import type { FurnitureItem } from '@house-plan-maker/shared'; + +interface OBB { + readonly id: string; + readonly cx: number; + readonly cy: number; + readonly halfW: number; + readonly halfD: number; + readonly cos: number; + readonly sin: number; +} + +function computeOBB(item: FurnitureItem): OBB { + const rad = (item.rotation * Math.PI) / 180; + return { + id: item.id, + cx: item.x + item.width / 2, + cy: item.y + item.depth / 2, + halfW: item.width / 2, + halfD: item.depth / 2, + cos: Math.cos(rad), + sin: Math.sin(rad), + }; +} + +/** Get the 4 corners of an OBB. */ +function getCorners(obb: OBB): [number, number][] { + const { cx, cy, halfW, halfD, cos, sin } = obb; + // Local corners at (±halfW, ±halfD), rotated and translated + return [ + [cx + halfW * cos - halfD * sin, cy + halfW * sin + halfD * cos], + [cx - halfW * cos - halfD * sin, cy - halfW * sin + halfD * cos], + [cx - halfW * cos + halfD * sin, cy - halfW * sin - halfD * cos], + [cx + halfW * cos + halfD * sin, cy + halfW * sin - halfD * cos], + ]; +} + +/** Project corners onto an axis and return [min, max]. */ +function projectOntoAxis(corners: [number, number][], ax: number, ay: number): [number, number] { + let min = Infinity; + let max = -Infinity; + for (const [x, y] of corners) { + const p = x * ax + y * ay; + if (p < min) min = p; + if (p > max) max = p; + } + return [min, max]; +} + +/** SAT overlap test for two OBBs. */ +function obbOverlap(a: OBB, b: OBB): boolean { + const cornersA = getCorners(a); + const cornersB = getCorners(b); + + // 4 potential separating axes: 2 from each OBB's edges + const axes: [number, number][] = [ + [a.cos, a.sin], + [-a.sin, a.cos], + [b.cos, b.sin], + [-b.sin, b.cos], + ]; + + for (const [ax, ay] of axes) { + const [minA, maxA] = projectOntoAxis(cornersA, ax, ay); + const [minB, maxB] = projectOntoAxis(cornersB, ax, ay); + if (maxA <= minB || maxB <= minA) { + return false; // Separating axis found — no overlap + } + } + + return true; // No separating axis — overlapping +} + +/** + * Find all furniture IDs that collide using proper OBB (rotation-aware) overlap. + */ +export function findCollidingFurniture( + items: readonly FurnitureItem[], +): ReadonlySet { + if (items.length < 2) return new Set(); + + const obbs = items.map(computeOBB); + const colliding = new Set(); + + for (let i = 0; i < obbs.length; i++) { + for (let j = i + 1; j < obbs.length; j++) { + // Check vertical overlap first (elevation + height) + const a = items[i]; + const b = items[j]; + const aBottom = a.elevationFromFloor; + const aTop = a.elevationFromFloor + a.height; + const bBottom = b.elevationFromFloor; + const bTop = b.elevationFromFloor + b.height; + if (aTop <= bBottom || bTop <= aBottom) continue; // no vertical overlap + + // Then check 2D footprint overlap + if (obbOverlap(obbs[i], obbs[j])) { + colliding.add(obbs[i].id); + colliding.add(obbs[j].id); + } + } + } + + return colliding; +} diff --git a/apps/client/src/components/editor/utils/geometry.ts b/apps/client/src/components/editor/utils/geometry.ts new file mode 100644 index 0000000..e7432ec --- /dev/null +++ b/apps/client/src/components/editor/utils/geometry.ts @@ -0,0 +1,196 @@ +import type { Point } from '@house-plan-maker/shared'; + +/** Distance between two points. */ +export function distance(a: Point, b: Point): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + return Math.sqrt(dx * dx + dy * dy); +} + +/** Snap a value to the nearest multiple of `grid`. */ +export function snapToGrid(value: number, grid: number): number { + return Math.round(value / grid) * grid; +} + +/** Snap a point to the nearest grid intersection. */ +export function snapPointToGrid(point: Point, grid: number): Point { + return { + x: snapToGrid(point.x, grid), + y: snapToGrid(point.y, grid), + }; +} + +/** Clamp a number between min and max. */ +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Project a point onto a line segment and return the closest point on the segment + * plus the parameter t (0..1) along the segment. + */ +export function projectPointOntoSegment( + point: Point, + segStart: Point, + segEnd: Point, +): { projected: Point; t: number; distance: number } { + const dx = segEnd.x - segStart.x; + const dy = segEnd.y - segStart.y; + const lengthSq = dx * dx + dy * dy; + + if (lengthSq === 0) { + return { projected: segStart, t: 0, distance: distance(point, segStart) }; + } + + const t = clamp( + ((point.x - segStart.x) * dx + (point.y - segStart.y) * dy) / lengthSq, + 0, + 1, + ); + + const projected: Point = { + x: segStart.x + t * dx, + y: segStart.y + t * dy, + }; + + return { projected, t, distance: distance(point, projected) }; +} + +/** Compute the angle (in radians) of a segment from start to end. */ +export function segmentAngle(start: Point, end: Point): number { + return Math.atan2(end.y - start.y, end.x - start.x); +} + +/** Check if a point is inside a polygon (ray casting). */ +export function pointInPolygon(point: Point, polygon: readonly Point[]): boolean { + let inside = false; + const n = polygon.length; + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = polygon[i].x; + const yi = polygon[i].y; + const xj = polygon[j].x; + const yj = polygon[j].y; + + const intersect = + yi > point.y !== yj > point.y && + point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi; + + if (intersect) { + inside = !inside; + } + } + return inside; +} + +/** + * Compute bounding box of a set of points. + * Returns { minX, minY, maxX, maxY }. + */ +export function boundingBox(points: readonly Point[]): { + minX: number; + minY: number; + maxX: number; + maxY: number; +} { + if (points.length === 0) { + return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of points) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + return { minX, minY, maxX, maxY }; +} + +/** Check if two axis-aligned rectangles overlap. */ +export function rectsOverlap( + a: { x: number; y: number; width: number; height: number }, + b: { x: number; y: number; width: number; height: number }, +): boolean { + return ( + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y + ); +} + +/** + * Compute the area of a polygon using the Shoelace formula. + * Points should be ordered (CW or CCW); the result is always positive. + */ +export function polygonArea(points: readonly Point[]): number { + const n = points.length; + if (n < 3) return 0; + + let sum = 0; + for (let i = 0; i < n; i++) { + const current = points[i]; + const next = points[(i + 1) % n]; + sum += current.x * next.y - next.x * current.y; + } + return Math.abs(sum) / 2; +} + +/** + * Compute the perimeter of a polygon (sum of all edge lengths). + */ +export function polygonPerimeter(points: readonly Point[]): number { + const n = points.length; + if (n < 2) return 0; + + let perimeter = 0; + for (let i = 0; i < n; i++) { + perimeter += distance(points[i], points[(i + 1) % n]); + } + return perimeter; +} + +/** + * Compute the centroid of a polygon. + * Returns the geometric center (average of vertices for simple polygons). + */ +export function polygonCentroid(points: readonly Point[]): Point { + const n = points.length; + if (n === 0) return { x: 0, y: 0 }; + if (n === 1) return points[0]; + if (n === 2) return { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 }; + + let cx = 0; + let cy = 0; + let areaSum = 0; + + for (let i = 0; i < n; i++) { + const current = points[i]; + const next = points[(i + 1) % n]; + const cross = current.x * next.y - next.x * current.y; + cx += (current.x + next.x) * cross; + cy += (current.y + next.y) * cross; + areaSum += cross; + } + + const area6 = 3 * areaSum; // 6A / 2 = 3 * sum + if (Math.abs(area6) < 1e-10) { + // Degenerate polygon — fall back to average of vertices + let sx = 0; + let sy = 0; + for (const p of points) { + sx += p.x; + sy += p.y; + } + return { x: sx / n, y: sy / n }; + } + + return { x: cx / area6, y: cy / area6 }; +} + +/** Generate a simple unique ID for local use. */ +export function generateLocalId(): string { + return `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} diff --git a/apps/client/src/components/editor/utils/lightCoverage.ts b/apps/client/src/components/editor/utils/lightCoverage.ts new file mode 100644 index 0000000..2b06b4d --- /dev/null +++ b/apps/client/src/components/editor/utils/lightCoverage.ts @@ -0,0 +1,13 @@ +import type { ElectricalItem } from '@house-plan-maker/shared'; +import { ELECTRICAL_SYMBOL_DEFS } from '../symbols/electrical'; + +/** + * Get the coverage radius for a light fixture, if applicable. + * Returns the radius in meters, or null if not a light type. + */ +export function getLightCoverageRadius(item: ElectricalItem): number | null { + const def = ELECTRICAL_SYMBOL_DEFS.find( + (d) => d.type === item.type && d.coverageRadius != null, + ); + return def?.coverageRadius ?? null; +} diff --git a/apps/client/src/components/editor/utils/openingUtils.ts b/apps/client/src/components/editor/utils/openingUtils.ts new file mode 100644 index 0000000..9c5ec0c --- /dev/null +++ b/apps/client/src/components/editor/utils/openingUtils.ts @@ -0,0 +1,29 @@ +import type { WallOpening } from '@house-plan-maker/shared'; + +/** + * Check if a new opening at the given position would overlap with existing openings + * on the same wall. + */ +export function hasOverlap( + wallId: string, + position: number, + width: number, + existingOpenings: readonly WallOpening[], +): boolean { + const halfWidth = width / 2; + const newStart = position - halfWidth; + const newEnd = position + halfWidth; + + for (const opening of existingOpenings) { + if (opening.wallId !== wallId) continue; + + const existingStart = opening.positionAlongWall - opening.width / 2; + const existingEnd = opening.positionAlongWall + opening.width / 2; + + if (newStart < existingEnd && newEnd > existingStart) { + return true; + } + } + + return false; +} diff --git a/apps/client/src/components/editor/utils/projectionMapping.ts b/apps/client/src/components/editor/utils/projectionMapping.ts new file mode 100644 index 0000000..bdda2b1 --- /dev/null +++ b/apps/client/src/components/editor/utils/projectionMapping.ts @@ -0,0 +1,354 @@ +import type { Wall, WallOpening, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared'; +import { wallLength, wallStartEnd } from './wallUtils'; + +// ── Constants ── + +/** Standard door height in meters. */ +export const STANDARD_DOOR_HEIGHT = 2.1; + +/** Default elevation for electrical items when elevationFromFloor is null. */ +export const DEFAULT_ELEVATIONS: Record = { + OUTLET: 0.3, + SWITCH: 1.2, + LIGHT_WALL: 2.0, + JUNCTION_BOX: 0.3, +}; + +/** Electrical types that are always wall-mounted. */ +export const WALL_MOUNTED_ELECTRICAL_TYPES = new Set([ + 'OUTLET', + 'SWITCH', + 'LIGHT_WALL', + 'JUNCTION_BOX', +]); + +/** Wall-mounted furniture types that should appear in projection. */ +const WALL_MOUNTED_FURNITURE = new Set(['SHELF', 'BOOKCASE', 'WARDROBE']); + +// ── Types ── + +/** A projected item's position in the wall elevation coordinate system. */ +export interface ProjectedPosition { + /** Horizontal position along the wall from start, in meters. */ + readonly alongWall: number; + /** Vertical position from the floor, in meters. */ + readonly fromFloor: number; +} + +/** A projected rectangle in wall elevation coordinates. */ +export interface ProjectedRect { + /** Left edge position along wall, in meters. */ + readonly x: number; + /** Bottom edge distance from floor, in meters. */ + readonly y: number; + /** Width along wall, in meters. */ + readonly width: number; + /** Height from bottom to top, in meters. */ + readonly height: number; +} + +/** Information about an opening projected onto a wall. */ +export interface ProjectedOpening { + readonly opening: WallOpening; + readonly rect: ProjectedRect; +} + +/** Information about an electrical item projected onto a wall. */ +export interface ProjectedElectrical { + readonly item: ElectricalItem; + readonly position: ProjectedPosition; + readonly elevation: number; +} + +/** Information about a furniture item projected onto a wall. */ +export interface ProjectedFurniture { + readonly item: FurnitureItem; + readonly rect: ProjectedRect; +} + +/** Plinth segment (may be interrupted by door openings). */ +export interface PlinthSegment { + readonly x: number; + readonly width: number; + readonly height: number; +} + +// ── Coordinate Mapping ── + +/** Scale factor for converting meters to projection-view pixels. */ +export function projectionScale( + wallLengthM: number, + wallHeightM: number, + viewWidth: number, + viewHeight: number, + padding: number = 40, +): number { + const availableWidth = viewWidth - padding * 2; + const availableHeight = viewHeight - padding * 2; + if (wallLengthM <= 0 || wallHeightM <= 0) return 1; + return Math.min(availableWidth / wallLengthM, availableHeight / wallHeightM); +} + +/** Convert projection coordinates (meters) to view pixels. */ +export function projectionToPixel( + alongWall: number, + fromFloor: number, + wallHeightM: number, + scale: number, + padding: number = 40, +): { x: number; y: number } { + return { + x: padding + alongWall * scale, + // Y is inverted: floor is at bottom, ceiling at top + y: padding + (wallHeightM - fromFloor) * scale, + }; +} + +/** Convert view pixels back to projection coordinates (meters). Inverse of projectionToPixel. */ +export function pixelToProjection( + pixelX: number, + pixelY: number, + wallHeightM: number, + scale: number, + padding: number = 40, +): { alongWall: number; fromFloor: number } { + return { + alongWall: (pixelX - padding) / scale, + fromFloor: wallHeightM - (pixelY - padding) / scale, + }; +} + +// ── Opening Projection ── + +/** Get all openings that belong to a given wall, projected into wall elevation coords. */ +export function projectOpenings( + wall: Wall, + openings: readonly WallOpening[], +): readonly ProjectedOpening[] { + const wallLen = wallLength(wall); + return openings + .filter((o) => o.wallId === wall.id) + .map((opening) => { + const halfWidth = opening.width / 2; + const leftEdge = opening.positionAlongWall - halfWidth; + + const isDoor = opening.type === 'DOOR'; + const fromFloor = isDoor ? 0 : opening.elevationFromFloor; + const height = isDoor ? STANDARD_DOOR_HEIGHT : opening.height; + + return { + opening, + rect: { + x: Math.max(0, leftEdge), + y: fromFloor, + width: Math.min(opening.width, wallLen - Math.max(0, leftEdge)), + height, + }, + }; + }); +} + +// ── Electrical Projection ── + +/** Project wall-mounted electrical items onto a wall's elevation. */ +export function projectElectricalItems( + wall: Wall, + electricalItems: readonly ElectricalItem[], +): readonly ProjectedElectrical[] { + const { start, end } = wallStartEnd(wall); + const wallLen = wallLength(wall); + + if (wallLen === 0) return []; + + const dx = (end.x - start.x) / wallLen; + const dy = (end.y - start.y) / wallLen; + + // Match by wallId first; fall back to proximity for items whose wallId + // became stale after a save (wall IDs change on bulk replace) + const PROXIMITY_THRESHOLD = 0.3; + + return electricalItems + .filter((item) => { + // Exact wallId match + if (item.wallId === wall.id) return true; + // Proximity fallback for wall-mounted items with mismatched/null wallId + if (!item.wallId || WALL_MOUNTED_ELECTRICAL_TYPES.has(item.type)) { + const vx = item.x - start.x; + const vy = item.y - start.y; + const perpDist = Math.abs(vx * (-dy) + vy * dx); + const alongWall = vx * dx + vy * dy; + return perpDist < PROXIMITY_THRESHOLD && alongWall >= -0.1 && alongWall <= wallLen + 0.1; + } + return false; + }) + .map((item) => { + // Project item position onto wall axis + const vx = item.x - start.x; + const vy = item.y - start.y; + const alongWall = vx * dx + vy * dy; + + const elevation = + item.elevationFromFloor ?? DEFAULT_ELEVATIONS[item.type] ?? 1.0; + + return { + item, + position: { alongWall: Math.max(0, Math.min(wallLen, alongWall)), fromFloor: elevation }, + elevation, + }; + }); +} + +// ── Furniture Projection ── + +/** + * Compute the distance from the nearest edge of a furniture item to a wall. + * Returns the gap between the item's closest edge and the wall line. + */ +function furnitureEdgeDistanceToWall( + item: FurnitureItem, + wall: Wall, +): number { + const { start, end } = wallStartEnd(wall); + const wallLen = wallLength(wall); + if (wallLen === 0) return Infinity; + + const dx = (end.x - start.x) / wallLen; + const dy = (end.y - start.y) / wallLen; + + // x,y is top-left corner; compute center for distance calculation + const cx = item.x + item.width / 2; + const cy = item.y + item.depth / 2; + + // Vector from wall start to item center + const vx = cx - start.x; + const vy = cy - start.y; + + // Perpendicular distance from center to wall + const centerDist = Math.abs(vx * (-dy) + vy * dx); + + // Subtract the item's half-extent in the perpendicular direction + // (approximation: use the larger of width/depth halves) + const halfExtent = Math.max(item.width, item.depth) / 2; + const edgeDist = Math.max(0, centerDist - halfExtent); + + // Along-wall distance: item must overlap with the wall's length + const alongWall = vx * dx + vy * dy; + const halfWidth = Math.max(item.width, item.depth) / 2; + if (alongWall < -halfWidth || alongWall > wallLen + halfWidth) return Infinity; + + return edgeDist; +} + +/** Project furniture items that are near a wall into elevation coords. */ +export function projectFurnitureItems( + wall: Wall, + furnitureItems: readonly FurnitureItem[], + wallThreshold: number = 0.15, +): readonly ProjectedFurniture[] { + const { start, end } = wallStartEnd(wall); + const wallLen = wallLength(wall); + if (wallLen === 0) return []; + + const dx = (end.x - start.x) / wallLen; + const dy = (end.y - start.y) / wallLen; + + return furnitureItems + .filter((item) => { + const dist = furnitureEdgeDistanceToWall(item, wall); + return dist < wallThreshold; + }) + .map((item) => { + // x,y is top-left corner; compute center for wall projection + const cx = item.x + item.width / 2; + const cy = item.y + item.depth / 2; + const vx = cx - start.x; + const vy = cy - start.y; + const alongWall = vx * dx + vy * dy; + + // For wall projection, use the item's depth as the "width" we see from the side + // and height as the vertical extent + const projectedWidth = item.width; + const fromFloor = item.elevationFromFloor ?? 0; + + return { + item, + rect: { + x: Math.max(0, alongWall - projectedWidth / 2), + y: fromFloor, + width: projectedWidth, + height: item.height, + }, + }; + }); +} + +// ── Plinth Segments ── + +/** Compute plinth segments, interrupted at door openings. */ +export function computePlinthSegments( + wall: Wall, + openings: readonly WallOpening[], + plinthHeight: number, +): readonly PlinthSegment[] { + const wallLen = wallLength(wall); + if (wallLen <= 0 || plinthHeight <= 0) return []; + + // Collect door gaps (sorted by position) + const doors = openings + .filter((o) => o.wallId === wall.id && o.type === 'DOOR') + .map((o) => ({ + start: Math.max(0, o.positionAlongWall - o.width / 2), + end: Math.min(wallLen, o.positionAlongWall + o.width / 2), + })) + .sort((a, b) => a.start - b.start); + + if (doors.length === 0) { + return [{ x: 0, width: wallLen, height: plinthHeight }]; + } + + const segments: PlinthSegment[] = []; + let cursor = 0; + + for (const door of doors) { + if (door.start > cursor) { + segments.push({ + x: cursor, + width: door.start - cursor, + height: plinthHeight, + }); + } + cursor = door.end; + } + + if (cursor < wallLen) { + segments.push({ + x: cursor, + width: wallLen - cursor, + height: plinthHeight, + }); + } + + return segments; +} + +/** Get an i18n key for a wall direction label. */ +export function wallDirectionKey(wall: Wall): string { + switch (wall.direction) { + case 'NORTH': return 'wall.north'; + case 'SOUTH': return 'wall.south'; + case 'EAST': return 'wall.east'; + case 'WEST': return 'wall.west'; + default: return 'wall.other'; + } +} + +/** Get a human-readable label for a wall direction (legacy, non-i18n). */ +export function wallDirectionLabel(wall: Wall): string { + switch (wall.direction) { + case 'NORTH': return 'North Wall'; + case 'SOUTH': return 'South Wall'; + case 'EAST': return 'East Wall'; + case 'WEST': return 'West Wall'; + default: return `Wall ${wall.id.slice(0, 6)}`; + } +} diff --git a/apps/client/src/components/editor/utils/wallUtils.ts b/apps/client/src/components/editor/utils/wallUtils.ts new file mode 100644 index 0000000..756aeed --- /dev/null +++ b/apps/client/src/components/editor/utils/wallUtils.ts @@ -0,0 +1,191 @@ +import type { Point, Wall, WallOpening } from '@house-plan-maker/shared'; +import { distance, projectPointOntoSegment, segmentAngle } from './geometry'; + +/** Get the start/end points of a wall as Point objects. */ +export function wallStartEnd(wall: Wall): { start: Point; end: Point } { + return { + start: { x: wall.startX, y: wall.startY }, + end: { x: wall.endX, y: wall.endY }, + }; +} + +/** Compute the length of a wall segment. */ +export function wallLength(wall: Wall): number { + return distance( + { x: wall.startX, y: wall.startY }, + { x: wall.endX, y: wall.endY }, + ); +} + +/** Compute the angle of a wall in radians. */ +export function wallAngle(wall: Wall): number { + return segmentAngle( + { x: wall.startX, y: wall.startY }, + { x: wall.endX, y: wall.endY }, + ); +} + +/** + * Find the nearest wall to a point. Returns the wall, projected point, and distance. + * Returns null if no walls exist. + */ +export function findNearestWall( + point: Point, + walls: readonly Wall[], +): { + wall: Wall; + projected: Point; + positionAlongWall: number; + distance: number; +} | null { + if (walls.length === 0) return null; + + let best: { + wall: Wall; + projected: Point; + positionAlongWall: number; + distance: number; + } | null = null; + + for (const wall of walls) { + const { start, end } = wallStartEnd(wall); + const result = projectPointOntoSegment(point, start, end); + const len = wallLength(wall); + const posAlongWall = result.t * len; + + if (best === null || result.distance < best.distance) { + best = { + wall, + projected: result.projected, + positionAlongWall: posAlongWall, + distance: result.distance, + }; + } + } + + return best; +} + +/** + * Compute the position of an opening on a wall in world coordinates. + * Returns center point, angle, and the start/end points of the opening along the wall. + */ +export function openingWorldPosition( + opening: WallOpening, + wall: Wall, +): { + center: Point; + angle: number; + start: Point; + end: Point; +} { + const { start, end } = wallStartEnd(wall); + const len = wallLength(wall); + const angle = wallAngle(wall); + + if (len === 0) { + return { center: start, angle: 0, start, end: start }; + } + + const dx = (end.x - start.x) / len; + const dy = (end.y - start.y) / len; + + const halfWidth = opening.width / 2; + const centerPos = opening.positionAlongWall; + + const center: Point = { + x: start.x + dx * centerPos, + y: start.y + dy * centerPos, + }; + + const openingStart: Point = { + x: start.x + dx * (centerPos - halfWidth), + y: start.y + dy * (centerPos - halfWidth), + }; + + const openingEnd: Point = { + x: start.x + dx * (centerPos + halfWidth), + y: start.y + dy * (centerPos + halfWidth), + }; + + return { center, angle, start: openingStart, end: openingEnd }; +} + +/** + * Generate wall segments from a room's polygon shape. + * Each consecutive pair of points becomes a wall segment. + * The polygon is automatically closed (last point connects to first). + */ +export function wallsFromShape( + shape: readonly Point[], + thickness: number = 0.1, +): readonly { startX: number; startY: number; endX: number; endY: number; thickness: number }[] { + if (shape.length < 2) return []; + + const walls: { startX: number; startY: number; endX: number; endY: number; thickness: number }[] = []; + + for (let i = 0; i < shape.length; i++) { + const start = shape[i]; + const end = shape[(i + 1) % shape.length]; + walls.push({ + startX: start.x, + startY: start.y, + endX: end.x, + endY: end.y, + thickness, + }); + } + + return walls; +} + +/** + * Generate the wall outline polygon for rendering a wall with thickness. + * Returns four corner points forming the wall rectangle. + */ +/** + * Compute the wall outline polygon. The wall extends outward from the room + * boundary by its full thickness, never inward into the room. + * + * @param wall The wall segment + * @param roomCentroid Optional room centroid to determine outward direction. + * If not provided, the polygon extends equally on both sides (legacy behavior). + */ +export function wallOutlinePolygon(wall: Wall, roomCentroid?: Point): readonly Point[] { + const { start, end } = wallStartEnd(wall); + const angle = wallAngle(wall); + const thickness = wall.thickness; + + // Perpendicular unit vector + const nx = -Math.sin(angle); + const ny = Math.cos(angle); + + if (roomCentroid) { + // Determine which perpendicular direction points away from the room center + const wallMidX = (start.x + end.x) / 2; + const wallMidY = (start.y + end.y) / 2; + const toCenterX = roomCentroid.x - wallMidX; + const toCenterY = roomCentroid.y - wallMidY; + // Dot product: if positive, (nx,ny) points toward center → flip to point outward + const dot = toCenterX * nx + toCenterY * ny; + const outNx = dot > 0 ? -nx : nx; + const outNy = dot > 0 ? -ny : ny; + + // Wall sits on the room boundary; extend outward only + return [ + { x: start.x, y: start.y }, + { x: end.x, y: end.y }, + { x: end.x + outNx * thickness, y: end.y + outNy * thickness }, + { x: start.x + outNx * thickness, y: start.y + outNy * thickness }, + ]; + } + + // Fallback: extend half-thickness on each side + const halfThick = thickness / 2; + return [ + { x: start.x + nx * halfThick, y: start.y + ny * halfThick }, + { x: end.x + nx * halfThick, y: end.y + ny * halfThick }, + { x: end.x - nx * halfThick, y: end.y - ny * halfThick }, + { x: start.x - nx * halfThick, y: start.y - ny * halfThick }, + ]; +} diff --git a/apps/client/src/components/layout/AppShell.tsx b/apps/client/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..e789c61 --- /dev/null +++ b/apps/client/src/components/layout/AppShell.tsx @@ -0,0 +1,141 @@ +import { Outlet, Link, NavLink, useMatches } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from '../../contexts/ThemeContext'; +import styles from './app-shell.module.css'; + +interface CrumbHandle { + crumb?: string | ((data: unknown) => string); +} + +interface MatchWithHandle { + handle?: CrumbHandle; + data?: unknown; + pathname: string; +} + +export function AppShell() { + const { t, i18n } = useTranslation(); + const { theme, toggleTheme } = useTheme(); + const matches = useMatches() as MatchWithHandle[]; + + const crumbs = matches + .filter((m) => m.handle?.crumb) + .map((m) => { + const raw = + typeof m.handle!.crumb === 'function' + ? m.handle!.crumb(m.data) + : m.handle!.crumb!; + // Resolve i18n key if it looks like a translation key (contains a dot) + const label = raw.includes('.') ? t(raw) : raw; + return { label, path: m.pathname }; + }); + + const toggleLanguage = () => { + const nextLng = i18n.language.startsWith('ru') ? 'en' : 'ru'; + i18n.changeLanguage(nextLng); + }; + + return ( +
+ {/* Header */} +
+ + H + {t('app.title')} + + + {crumbs.length > 0 && ( + + )} + +
+ +
+ + +
+
+ + {/* Body */} +
+ {/* Sidebar (desktop) */} + + + {/* Main content */} +
+
+ +
+
+
+ + {/* Bottom nav (mobile) */} + +
+ ); +} diff --git a/apps/client/src/components/layout/app-shell.module.css b/apps/client/src/components/layout/app-shell.module.css new file mode 100644 index 0000000..bfc0416 --- /dev/null +++ b/apps/client/src/components/layout/app-shell.module.css @@ -0,0 +1,241 @@ +.shell { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ── Header ── */ + +.header { + position: sticky; + top: 0; + z-index: var(--z-sticky); + display: flex; + align-items: center; + height: var(--header-height); + padding: 0 var(--space-6); + background-color: var(--color-bg-elevated); + border-bottom: 1px solid var(--color-border); + gap: var(--space-4); +} + +.logo { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + text-decoration: none; + letter-spacing: var(--letter-spacing-tight); + white-space: nowrap; +} + +.logo:hover { + text-decoration: none; + color: var(--color-text-primary); +} + +.logoIcon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background-color: var(--color-accent-600); + color: var(--color-text-on-accent); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + overflow: hidden; +} + +.breadcrumbSep { + color: var(--color-neutral-400); + flex-shrink: 0; +} + +.breadcrumbLink { + color: var(--color-text-secondary); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.breadcrumbLink:hover { + color: var(--color-accent-600); + text-decoration: none; +} + +.breadcrumbCurrent { + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.headerSpacer { + flex: 1; +} + +.headerActions { + display: flex; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; +} + +.headerBtn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast); + font-family: var(--font-family); +} + +.headerBtn:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +/* ── Body ── */ + +.body { + flex: 1; + display: flex; +} + +/* ── Sidebar ── */ + +.sidebar { + display: none; + width: var(--sidebar-width); + border-right: 1px solid var(--color-border); + background-color: var(--color-bg-elevated); + padding: var(--space-4) 0; + flex-shrink: 0; +} + +.navItem { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-5); + font-size: var(--font-size-base); + color: var(--color-text-secondary); + text-decoration: none; + transition: background-color var(--transition-fast), + color var(--transition-fast); +} + +.navItem:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); + text-decoration: none; +} + +.navItemActive { + color: var(--color-accent-700); + background-color: var(--color-accent-50); + font-weight: var(--font-weight-medium); +} + +.navItemActive:hover { + background-color: var(--color-accent-50); + color: var(--color-accent-700); +} + +.navIcon { + font-size: var(--font-size-md); + flex-shrink: 0; + width: 20px; + text-align: center; +} + +/* ── Content ── */ + +.content { + flex: 1; + min-width: 0; + padding: var(--space-6); + overflow: hidden; +} + +.contentInner { + width: 100%; + height: 100%; +} + +/* ── Bottom nav (mobile) ── */ + +.bottomNav { + display: flex; + align-items: center; + justify-content: space-around; + height: 56px; + border-top: 1px solid var(--color-border); + background-color: var(--color-bg-elevated); + position: sticky; + bottom: 0; + z-index: var(--z-sticky); +} + +.bottomNavItem { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: var(--space-1) var(--space-3); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + text-decoration: none; + transition: color var(--transition-fast); +} + +.bottomNavItem:hover { + text-decoration: none; +} + +.bottomNavItemActive { + color: var(--color-accent-600); + font-weight: var(--font-weight-medium); +} + +.bottomNavIcon { + font-size: var(--font-size-lg); +} + +/* ── Desktop ── */ + +@media (min-width: 768px) { + .sidebar { + display: block; + } + + .bottomNav { + display: none; + } + + .content { + padding: var(--space-8); + } +} diff --git a/apps/client/src/components/rooms/RoomCard.tsx b/apps/client/src/components/rooms/RoomCard.tsx new file mode 100644 index 0000000..dcd201d --- /dev/null +++ b/apps/client/src/components/rooms/RoomCard.tsx @@ -0,0 +1,119 @@ +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import type { Room, Point } from '@house-plan-maker/shared'; +import { Card, CardHeader } from '../ui/Card'; +import { Button } from '../ui/Button'; +import styles from './room-card.module.css'; + +interface RoomCardProps { + room: Room; + apartmentId: string; + onEdit: (room: Room) => void; + onDelete: (room: Room) => void; +} + +function ShapePreview({ shape }: { shape: readonly Point[] }) { + if (shape.length < 3) { + return ( + + + + ); + } + + const xs = shape.map((p) => p.x); + const ys = shape.map((p) => p.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const shapeWidth = maxX - minX || 1; + const shapeHeight = maxY - minY || 1; + + const padding = 8; + const availableSize = 72 - padding * 2; + const scale = Math.min(availableSize / shapeWidth, availableSize / shapeHeight); + const offsetX = padding + (availableSize - shapeWidth * scale) / 2; + const offsetY = padding + (availableSize - shapeHeight * scale) / 2; + + const points = shape + .map((p) => { + const sx = (p.x - minX) * scale + offsetX; + const sy = (p.y - minY) * scale + offsetY; + return `${sx},${sy}`; + }) + .join(' '); + + return ( + + + + ); +} + +export function RoomCard({ room, apartmentId, onEdit, onDelete }: RoomCardProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleClick = () => { + navigate(`/apartments/${apartmentId}/rooms/${room.id}/editor`); + }; + + const handleEdit = (event: React.MouseEvent) => { + event.stopPropagation(); + onEdit(room); + }; + + const handleDelete = (event: React.MouseEvent) => { + event.stopPropagation(); + onDelete(room); + }; + + const dimensions = + room.width != null && room.height != null + ? t('rooms.dimensions', { width: room.width, height: room.height }) + : t('rooms.vertices', { count: room.shape.length }); + + return ( + + +
+
+ +
+
+

{room.name}

+

{dimensions}

+
+ {t('rooms.wallHeight', { value: room.wallHeight })} + {t('rooms.plinth', { value: room.plinthHeight })} +
+
+
+
+ + +
+
+
+ ); +} diff --git a/apps/client/src/components/rooms/RoomFormModal.tsx b/apps/client/src/components/rooms/RoomFormModal.tsx new file mode 100644 index 0000000..de91f40 --- /dev/null +++ b/apps/client/src/components/rooms/RoomFormModal.tsx @@ -0,0 +1,333 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Room, Point } from '@house-plan-maker/shared'; +import { createRoomSchema } from '@house-plan-maker/shared'; +import { Modal } from '../ui/Modal'; +import { Input } from '../ui/Input'; +import { Button } from '../ui/Button'; +import styles from './room-form-modal.module.css'; + +type ShapeType = 'rectangular' | 'custom'; + +interface RoomFormData { + name: string; + shapeType: ShapeType; + width: string; + height: string; + wallHeight: string; + plinthHeight: string; + plinthThickness: string; +} + +interface RoomFormModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: { + name: string; + shape: readonly Point[]; + width: number | null; + height: number | null; + wallHeight: number; + plinthHeight: number; + plinthThickness: number; + }) => void; + room?: Room | null; + loading?: boolean; +} + +interface FormErrors { + name?: string; + width?: string; + height?: string; + wallHeight?: string; + plinthHeight?: string; + plinthThickness?: string; +} + +function rectangularShape(w: number, h: number): readonly Point[] { + return [ + { x: 0, y: 0 }, + { x: w, y: 0 }, + { x: w, y: h }, + { x: 0, y: h }, + ]; +} + +function isRectangular(room: Room): boolean { + return room.width != null && room.height != null; +} + +export function RoomFormModal({ + open, + onClose, + onSubmit, + room, + loading = false, +}: RoomFormModalProps) { + const { t } = useTranslation(); + const [form, setForm] = useState({ + name: '', + shapeType: 'rectangular', + width: '', + height: '', + wallHeight: '2.5', + plinthHeight: '0.06', + plinthThickness: '0.01', + }); + const [errors, setErrors] = useState({}); + + const isEditing = room != null; + + useEffect(() => { + if (open) { + if (room) { + setForm({ + name: room.name, + shapeType: isRectangular(room) ? 'rectangular' : 'custom', + width: room.width != null ? String(room.width) : '', + height: room.height != null ? String(room.height) : '', + wallHeight: String(room.wallHeight), + plinthHeight: String(room.plinthHeight), + plinthThickness: String(room.plinthThickness), + }); + } else { + setForm({ + name: '', + shapeType: 'rectangular', + width: '', + height: '', + wallHeight: '2.5', + plinthHeight: '0.06', + plinthThickness: '0.01', + }); + } + setErrors({}); + } + }, [open, room]); + + const updateField = (field: keyof RoomFormData, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + }; + + const validate = (): boolean => { + const fieldErrors: FormErrors = {}; + + const width = form.width.trim() ? Number(form.width) : null; + const height = form.height.trim() ? Number(form.height) : null; + const wallHeight = Number(form.wallHeight) || 2.5; + const plinthHeight = form.plinthHeight.trim() ? Number(form.plinthHeight) : 0.06; + const plinthThickness = form.plinthThickness.trim() ? Number(form.plinthThickness) : 0.01; + + let shape: readonly Point[] | undefined; + if (form.shapeType === 'rectangular') { + if (!width || width <= 0) { + fieldErrors.width = t('roomForm.widthError'); + } + if (!height || height <= 0) { + fieldErrors.height = t('roomForm.heightError'); + } + if (width && height && width > 0 && height > 0) { + shape = rectangularShape(width, height); + } + } else { + // Custom polygon: keep existing shape for editing, empty for new + shape = room?.shape; + } + + const result = createRoomSchema.safeParse({ + name: form.name.trim(), + shape, + width, + height, + wallHeight, + plinthHeight, + plinthThickness, + }); + + if (!result.success) { + for (const issue of result.error.issues) { + const field = issue.path[0] as keyof FormErrors; + if (!fieldErrors[field]) { + fieldErrors[field] = issue.message; + } + } + } + + if (Object.keys(fieldErrors).length > 0) { + setErrors(fieldErrors); + return false; + } + + setErrors({}); + return true; + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!validate()) return; + + const width = form.width.trim() ? Number(form.width) : null; + const height = form.height.trim() ? Number(form.height) : null; + const wallHeight = Number(form.wallHeight) || 2.5; + const plinthHeight = form.plinthHeight.trim() ? Number(form.plinthHeight) : 0.06; + const plinthThickness = form.plinthThickness.trim() ? Number(form.plinthThickness) : 0.01; + + let shape: readonly Point[]; + if (form.shapeType === 'rectangular' && width && height) { + shape = rectangularShape(width, height); + } else { + shape = room?.shape ?? []; + } + + onSubmit({ + name: form.name.trim(), + shape, + width, + height, + wallHeight, + plinthHeight, + plinthThickness, + }); + }; + + return ( + + + + + } + > +
+ updateField('name', e.target.value)} + error={errors.name} + placeholder={t('roomForm.namePlaceholder')} + autoFocus + required + /> + + {/* Shape type selector */} +
+ {t('roomForm.shape')} +
+ + +
+
+ + {form.shapeType === 'rectangular' ? ( +
+ updateField('width', e.target.value)} + error={errors.width} + placeholder={t('roomForm.widthPlaceholder')} + min="0.1" + step="0.01" + required + /> + updateField('height', e.target.value)} + error={errors.height} + placeholder={t('roomForm.heightPlaceholder')} + min="0.1" + step="0.01" + required + /> +
+ ) : ( +
+ {t('roomForm.customNote')} + {isEditing && room?.shape && room.shape.length > 0 + ? t('roomForm.currentVertices', { count: room.shape.length }) + : t('roomForm.defaultShape')} +
+ )} + + {t('roomForm.wallProperties')} + + updateField('wallHeight', e.target.value)} + error={errors.wallHeight} + placeholder="2.5" + min="0.1" + step="0.01" + /> + + {t('roomForm.plinthProperties')} + +
+ updateField('plinthHeight', e.target.value)} + error={errors.plinthHeight} + placeholder="0.06" + min="0" + step="0.001" + hint={t('roomForm.plinthHeightHint')} + /> + updateField('plinthThickness', e.target.value)} + error={errors.plinthThickness} + placeholder="0.01" + min="0" + step="0.001" + hint={t('roomForm.plinthThicknessHint')} + /> +
+
+
+ ); +} diff --git a/apps/client/src/components/rooms/room-card.module.css b/apps/client/src/components/rooms/room-card.module.css new file mode 100644 index 0000000..7a6d0a8 --- /dev/null +++ b/apps/client/src/components/rooms/room-card.module.css @@ -0,0 +1,60 @@ +.card { + position: relative; +} + +.cardBody { + display: flex; + gap: var(--space-4); + align-items: flex-start; +} + +.preview { + flex-shrink: 0; + width: 72px; + height: 72px; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.previewSvg { + width: 100%; + height: 100%; +} + +.info { + flex: 1; + min-width: 0; +} + +.name { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0; +} + +.dimensions { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin-top: var(--space-1); +} + +.details { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); + margin-top: var(--space-2); + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.actions { + display: flex; + gap: var(--space-1); + flex-shrink: 0; +} diff --git a/apps/client/src/components/rooms/room-form-modal.module.css b/apps/client/src/components/rooms/room-form-modal.module.css new file mode 100644 index 0000000..6044e60 --- /dev/null +++ b/apps/client/src/components/rooms/room-form-modal.module.css @@ -0,0 +1,84 @@ +.form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.shapeSelector { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.shapeLabel { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); +} + +.shapeOptions { + display: flex; + gap: var(--space-2); +} + +.shapeOption { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-elevated); + cursor: pointer; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + transition: border-color var(--transition-fast), + color var(--transition-fast), + background-color var(--transition-fast); +} + +.shapeOption:hover { + border-color: var(--color-neutral-400); + color: var(--color-text-primary); +} + +.shapeOptionActive { + border-color: var(--color-accent-500); + color: var(--color-accent-700); + background-color: var(--color-accent-50); +} + +.shapeOptionActive:hover { + border-color: var(--color-accent-500); + color: var(--color-accent-700); +} + +.row { + display: flex; + gap: var(--space-3); +} + +.row > * { + flex: 1; +} + +.sectionTitle { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + margin-top: var(--space-2); +} + +.customNote { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + padding: var(--space-3); + background-color: var(--color-neutral-50); + border-radius: var(--radius-md); + border: 1px dashed var(--color-border); +} diff --git a/apps/client/src/components/shared/ConfirmDialog.tsx b/apps/client/src/components/shared/ConfirmDialog.tsx new file mode 100644 index 0000000..0adfdb1 --- /dev/null +++ b/apps/client/src/components/shared/ConfirmDialog.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next'; +import { Modal } from '../ui/Modal'; +import { Button } from '../ui/Button'; + +interface ConfirmDialogProps { + readonly open: boolean; + readonly onClose: () => void; + readonly onConfirm: () => void; + readonly title: string; + readonly message: string; + readonly confirmLabel?: string; + readonly loading?: boolean; +} + +export function ConfirmDialog({ + open, + onClose, + onConfirm, + title, + message, + confirmLabel, + loading = false, +}: ConfirmDialogProps) { + const { t } = useTranslation(); + + return ( + + + + + } + > +

{message}

+
+ ); +} diff --git a/apps/client/src/components/ui/Button.tsx b/apps/client/src/components/ui/Button.tsx new file mode 100644 index 0000000..9e1d803 --- /dev/null +++ b/apps/client/src/components/ui/Button.tsx @@ -0,0 +1,33 @@ +import { type ButtonHTMLAttributes } from 'react'; +import styles from './button.module.css'; + +type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; +} + +export function Button({ + variant = 'primary', + size = 'md', + className, + children, + ...rest +}: ButtonProps) { + const classNames = [ + styles.button, + styles[variant], + styles[size], + className, + ] + .filter(Boolean) + .join(' '); + + return ( + + ); +} diff --git a/apps/client/src/components/ui/Card.tsx b/apps/client/src/components/ui/Card.tsx new file mode 100644 index 0000000..e011230 --- /dev/null +++ b/apps/client/src/components/ui/Card.tsx @@ -0,0 +1,81 @@ +import { type HTMLAttributes, type ReactNode } from 'react'; +import styles from './card.module.css'; + +interface CardProps extends HTMLAttributes { + readonly interactive?: boolean; + readonly children: ReactNode; +} + +export function Card({ + interactive = false, + className, + children, + onClick, + ...rest +}: CardProps) { + const classNames = [ + styles.card, + interactive ? styles.interactive : undefined, + className, + ] + .filter(Boolean) + .join(' '); + + const handleKeyDown = interactive && onClick + ? (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick(event as unknown as React.MouseEvent); + } + } + : undefined; + + return ( +
+ {children} +
+ ); +} + +export function CardHeader({ + className, + children, + ...rest +}: HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +export function CardBody({ + className, + children, + ...rest +}: HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +export function CardFooter({ + className, + children, + ...rest +}: HTMLAttributes) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/client/src/components/ui/EmptyState.tsx b/apps/client/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..a7dad9a --- /dev/null +++ b/apps/client/src/components/ui/EmptyState.tsx @@ -0,0 +1,20 @@ +import { type ReactNode } from 'react'; +import styles from './empty-state.module.css'; + +interface EmptyStateProps { + icon?: ReactNode; + title: string; + description?: string; + action?: ReactNode; +} + +export function EmptyState({ icon, title, description, action }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description &&

{description}

} + {action} +
+ ); +} diff --git a/apps/client/src/components/ui/ErrorBanner.tsx b/apps/client/src/components/ui/ErrorBanner.tsx new file mode 100644 index 0000000..449c7ca --- /dev/null +++ b/apps/client/src/components/ui/ErrorBanner.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; +import styles from './error-banner.module.css'; + +interface ErrorBannerProps { + readonly message: string; + readonly onDismiss?: () => void; +} + +export function ErrorBanner({ message, onDismiss }: ErrorBannerProps) { + const { t } = useTranslation(); + + return ( +
+ + {message} + {onDismiss && ( + + )} +
+ ); +} diff --git a/apps/client/src/components/ui/Input.tsx b/apps/client/src/components/ui/Input.tsx new file mode 100644 index 0000000..a57cd4c --- /dev/null +++ b/apps/client/src/components/ui/Input.tsx @@ -0,0 +1,45 @@ +import { type InputHTMLAttributes, useId } from 'react'; +import styles from './input.module.css'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + hint?: string; +} + +export function Input({ + label, + error, + hint, + className, + id: externalId, + ...rest +}: InputProps) { + const generatedId = useId(); + const inputId = externalId ?? generatedId; + + const fieldClassNames = [ + styles.field, + error ? styles.hasError : undefined, + className, + ] + .filter(Boolean) + .join(' '); + + return ( +
+ {label && ( + + )} + + {error && ( + + {error} + + )} + {hint && !error && {hint}} +
+ ); +} diff --git a/apps/client/src/components/ui/LoadingSpinner.tsx b/apps/client/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..ed910f2 --- /dev/null +++ b/apps/client/src/components/ui/LoadingSpinner.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import styles from './loading-spinner.module.css'; + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg'; +} + +export function LoadingSpinner({ size = 'md' }: LoadingSpinnerProps) { + const { t } = useTranslation(); + + return ( +
+
+ {t('common.loading')} +
+ ); +} diff --git a/apps/client/src/components/ui/Modal.tsx b/apps/client/src/components/ui/Modal.tsx new file mode 100644 index 0000000..c6fb32d --- /dev/null +++ b/apps/client/src/components/ui/Modal.tsx @@ -0,0 +1,136 @@ +import { type ReactNode, useEffect, useCallback, useRef } from 'react'; +import styles from './modal.module.css'; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +interface ModalProps { + readonly open: boolean; + readonly onClose: () => void; + readonly title: string; + readonly children: ReactNode; + readonly footer?: ReactNode; +} + +export function Modal({ open, onClose, title, children, footer }: ModalProps) { + const backdropRef = useRef(null); + const modalRef = useRef(null); + const mouseDownTargetRef = useRef(null); + const triggerRef = useRef(null); + + // Capture the element that had focus when the modal opened + useEffect(() => { + if (open) { + triggerRef.current = document.activeElement as HTMLElement | null; + } + }, [open]); + + // Auto-focus first focusable element and restore focus on close + useEffect(() => { + if (open && modalRef.current) { + const focusable = modalRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusable.length > 0) { + focusable[0].focus(); + } + } + + return () => { + if (!open && triggerRef.current && typeof triggerRef.current.focus === 'function') { + triggerRef.current.focus(); + triggerRef.current = null; + } + }; + }, [open]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + return; + } + + // Focus trap: cycle Tab/Shift+Tab within the modal + if (event.key === 'Tab' && modalRef.current) { + const focusable = modalRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusable.length === 0) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey) { + if (document.activeElement === first) { + event.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + } + }, + [onClose], + ); + + useEffect(() => { + if (open) { + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + } + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [open, handleKeyDown]); + + if (!open) { + return null; + } + + const handleBackdropMouseDown = (event: React.MouseEvent) => { + mouseDownTargetRef.current = event.target; + }; + + const handleBackdropMouseUp = (event: React.MouseEvent) => { + if ( + event.target === backdropRef.current && + mouseDownTargetRef.current === backdropRef.current + ) { + onClose(); + } + mouseDownTargetRef.current = null; + }; + + return ( +
+
+
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/apps/client/src/components/ui/Toast.tsx b/apps/client/src/components/ui/Toast.tsx new file mode 100644 index 0000000..f2fe9a4 --- /dev/null +++ b/apps/client/src/components/ui/Toast.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './toast.module.css'; + +export type ToastType = 'success' | 'error' | 'info'; + +export interface ToastItem { + readonly id: string; + readonly message: string; + readonly type: ToastType; + readonly duration: number; +} + +interface ToastProps { + readonly toasts: readonly ToastItem[]; + readonly onDismiss: (id: string) => void; +} + +export function ToastContainer({ toasts, onDismiss }: ToastProps) { + const { t } = useTranslation(); + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} + +interface ToastEntryProps { + readonly toast: ToastItem; + readonly onDismiss: (id: string) => void; +} + +function ToastEntry({ toast, onDismiss }: ToastEntryProps) { + const { t } = useTranslation(); + const [exiting, setExiting] = useState(false); + + const handleDismiss = useCallback(() => { + setExiting(true); + setTimeout(() => onDismiss(toast.id), 200); + }, [toast.id, onDismiss]); + + useEffect(() => { + const timer = setTimeout(() => { + handleDismiss(); + }, toast.duration); + + return () => clearTimeout(timer); + }, [toast.duration, handleDismiss]); + + const typeClass = styles[toast.type] ?? ''; + const className = [styles.toast, typeClass, exiting ? styles.exiting : ''] + .filter(Boolean) + .join(' '); + + return ( +
+ {toast.message} + +
+ ); +} diff --git a/apps/client/src/components/ui/Tooltip.tsx b/apps/client/src/components/ui/Tooltip.tsx new file mode 100644 index 0000000..60ba048 --- /dev/null +++ b/apps/client/src/components/ui/Tooltip.tsx @@ -0,0 +1,86 @@ +import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from 'react'; +import styles from './tooltip.module.css'; + +type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'; + +interface TooltipProps { + readonly content: string; + readonly position?: TooltipPosition; + readonly delay?: number; + readonly children: ReactNode; +} + +export function Tooltip({ + content, + position = 'top', + delay = 300, + children, +}: TooltipProps) { + const [visible, setVisible] = useState(false); + const [adjustedPosition, setAdjustedPosition] = useState(position); + const timerRef = useRef | null>(null); + const wrapperRef = useRef(null); + const tooltipId = useId(); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const show = useCallback(() => { + clearTimer(); + timerRef.current = setTimeout(() => { + // Adjust position if element is near viewport edge + if (wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect(); + let resolved = position; + if (position === 'top' && rect.top < 40) { + resolved = 'bottom'; + } else if (position === 'bottom' && rect.bottom > window.innerHeight - 40) { + resolved = 'top'; + } else if (position === 'left' && rect.left < 100) { + resolved = 'right'; + } else if (position === 'right' && rect.right > window.innerWidth - 100) { + resolved = 'left'; + } + setAdjustedPosition(resolved); + } + setVisible(true); + }, delay); + }, [clearTimer, delay, position]); + + const hide = useCallback(() => { + clearTimer(); + setVisible(false); + }, [clearTimer]); + + useEffect(() => { + return clearTimer; + }, [clearTimer]); + + return ( +
+ + {children} + + {visible && ( + + )} +
+ ); +} diff --git a/apps/client/src/components/ui/button.module.css b/apps/client/src/components/ui/button.module.css new file mode 100644 index 0000000..a3e6f63 --- /dev/null +++ b/apps/client/src/components/ui/button.module.css @@ -0,0 +1,91 @@ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + border: 1px solid transparent; + border-radius: var(--radius-md); + font-weight: var(--font-weight-medium); + font-size: var(--font-size-base); + line-height: 1; + cursor: pointer; + transition: background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast), + box-shadow var(--transition-fast); + white-space: nowrap; + user-select: none; +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Sizes ── */ + +.sm { + height: 32px; + padding: 0 var(--space-3); + font-size: var(--font-size-sm); +} + +.md { + height: 36px; + padding: 0 var(--space-4); +} + +.lg { + height: 42px; + padding: 0 var(--space-6); + font-size: var(--font-size-md); +} + +/* ── Variants ── */ + +.primary { + background-color: var(--color-accent-600); + color: var(--color-text-on-accent); +} + +.primary:hover:not(:disabled) { + background-color: var(--color-accent-700); +} + +.primary:active:not(:disabled) { + background-color: var(--color-accent-800); +} + +.secondary { + background-color: var(--color-bg-elevated); + color: var(--color-text-primary); + border-color: var(--color-border-strong); +} + +.secondary:hover:not(:disabled) { + background-color: var(--color-bg-hover); + border-color: var(--color-neutral-400); +} + +.ghost { + background-color: transparent; + color: var(--color-text-secondary); +} + +.ghost:hover:not(:disabled) { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.danger { + background-color: var(--color-danger-600); + color: var(--color-text-on-accent); +} + +.danger:hover:not(:disabled) { + background-color: var(--color-danger-700); +} + +.danger:active:not(:disabled) { + background-color: var(--color-danger-700); +} diff --git a/apps/client/src/components/ui/card.module.css b/apps/client/src/components/ui/card.module.css new file mode 100644 index 0000000..c9e2724 --- /dev/null +++ b/apps/client/src/components/ui/card.module.css @@ -0,0 +1,38 @@ +.card { + background-color: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow-xs); + transition: box-shadow var(--transition-base), + border-color var(--transition-base); +} + +.interactive { + cursor: pointer; +} + +.interactive:hover { + box-shadow: var(--shadow-md); + border-color: var(--color-border-strong); +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + +.body { + margin-top: var(--space-3); +} + +.footer { + margin-top: var(--space-4); + padding-top: var(--space-3); + border-top: 1px solid var(--color-border); + display: flex; + align-items: center; + gap: var(--space-2); +} diff --git a/apps/client/src/components/ui/empty-state.module.css b/apps/client/src/components/ui/empty-state.module.css new file mode 100644 index 0000000..ae3c5f9 --- /dev/null +++ b/apps/client/src/components/ui/empty-state.module.css @@ -0,0 +1,29 @@ +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-12) var(--space-6); + text-align: center; +} + +.icon { + width: 64px; + height: 64px; + margin-bottom: var(--space-4); + color: var(--color-neutral-300); +} + +.title { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin-bottom: var(--space-2); +} + +.description { + font-size: var(--font-size-base); + color: var(--color-text-secondary); + max-width: 360px; + margin-bottom: var(--space-6); +} diff --git a/apps/client/src/components/ui/error-banner.module.css b/apps/client/src/components/ui/error-banner.module.css new file mode 100644 index 0000000..7245376 --- /dev/null +++ b/apps/client/src/components/ui/error-banner.module.css @@ -0,0 +1,42 @@ +.banner { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background-color: var(--color-danger-50); + border: 1px solid var(--color-danger-100); + border-radius: var(--radius-md); + color: var(--color-danger-700); + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); +} + +.icon { + flex-shrink: 0; + margin-top: 1px; + font-size: var(--font-size-md); +} + +.message { + flex: 1; +} + +.dismiss { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-danger-600); + font-size: var(--font-size-sm); + cursor: pointer; + transition: background-color var(--transition-fast); +} + +.dismiss:hover { + background-color: var(--color-danger-100); +} diff --git a/apps/client/src/components/ui/input.module.css b/apps/client/src/components/ui/input.module.css new file mode 100644 index 0000000..b22d1ad --- /dev/null +++ b/apps/client/src/components/ui/input.module.css @@ -0,0 +1,61 @@ +.field { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); +} + +.input { + height: 36px; + padding: 0 var(--space-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background-color: var(--color-bg-elevated); + color: var(--color-text-primary); + font-size: var(--font-size-base); + transition: border-color var(--transition-fast), + box-shadow var(--transition-fast); +} + +.input::placeholder { + color: var(--color-text-muted); +} + +.input:hover:not(:disabled) { + border-color: var(--color-neutral-400); +} + +.input:focus { + outline: none; + border-color: var(--color-accent-500); + box-shadow: 0 0 0 3px var(--color-accent-100); +} + +.input:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: var(--color-neutral-100); +} + +.hasError .input { + border-color: var(--color-danger-500); +} + +.hasError .input:focus { + box-shadow: 0 0 0 3px var(--color-danger-100); +} + +.error { + font-size: var(--font-size-xs); + color: var(--color-danger-600); +} + +.hint { + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} diff --git a/apps/client/src/components/ui/loading-spinner.module.css b/apps/client/src/components/ui/loading-spinner.module.css new file mode 100644 index 0000000..c8961dd --- /dev/null +++ b/apps/client/src/components/ui/loading-spinner.module.css @@ -0,0 +1,31 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-8); +} + +.spinner { + width: 28px; + height: 28px; + border: 3px solid var(--color-neutral-200); + border-top-color: var(--color-accent-600); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.sm .spinner { + width: 16px; + height: 16px; + border-width: 2px; +} + +.lg .spinner { + width: 40px; + height: 40px; + border-width: 4px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/apps/client/src/components/ui/modal.module.css b/apps/client/src/components/ui/modal.module.css new file mode 100644 index 0000000..8c5d33d --- /dev/null +++ b/apps/client/src/components/ui/modal.module.css @@ -0,0 +1,86 @@ +.backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + z-index: var(--z-modal-backdrop); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + animation: fadeIn var(--transition-fast) ease; +} + +.modal { + background-color: var(--color-bg-elevated); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + z-index: var(--z-modal); + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + animation: slideUp var(--transition-base) ease; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5) var(--space-6); + border-bottom: 1px solid var(--color-border); +} + +.title { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-secondary); + font-size: var(--font-size-lg); + transition: background-color var(--transition-fast), + color var(--transition-fast); +} + +.closeButton:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.body { + padding: var(--space-6); +} + +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-3); + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--color-border); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/apps/client/src/components/ui/toast.module.css b/apps/client/src/components/ui/toast.module.css new file mode 100644 index 0000000..98d8149 --- /dev/null +++ b/apps/client/src/components/ui/toast.module.css @@ -0,0 +1,93 @@ +.container { + position: fixed; + bottom: var(--space-6); + right: var(--space-6); + z-index: var(--z-toast); + display: flex; + flex-direction: column-reverse; + gap: var(--space-2); + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-normal); + min-width: 240px; + max-width: 400px; + pointer-events: auto; + animation: toastIn var(--transition-base) ease forwards; +} + +.toast.exiting { + animation: toastOut var(--transition-base) ease forwards; +} + +.success { + background-color: var(--color-success-50); + border: 1px solid var(--color-success-500); + color: var(--color-success-700); +} + +.error { + background-color: var(--color-danger-50); + border: 1px solid var(--color-danger-500); + color: var(--color-danger-700); +} + +.info { + background-color: var(--color-accent-50); + border: 1px solid var(--color-accent-400); + color: var(--color-accent-800); +} + +.message { + flex: 1; +} + +.dismiss { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + opacity: 0.6; + cursor: pointer; + transition: opacity var(--transition-fast); +} + +.dismiss:hover { + opacity: 1; +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateX(16px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes toastOut { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(16px); + } +} diff --git a/apps/client/src/components/ui/tooltip.module.css b/apps/client/src/components/ui/tooltip.module.css new file mode 100644 index 0000000..96b55aa --- /dev/null +++ b/apps/client/src/components/ui/tooltip.module.css @@ -0,0 +1,56 @@ +.wrapper { + position: relative; + display: inline-flex; +} + +.tooltip { + position: absolute; + z-index: var(--z-dropdown); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-md); + background-color: var(--color-neutral-800); + color: var(--color-neutral-0); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-normal); + white-space: nowrap; + pointer-events: none; + animation: tooltipIn var(--transition-fast) ease; +} + +.top { + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: var(--space-1); +} + +.bottom { + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: var(--space-1); +} + +.left { + right: 100%; + top: 50%; + transform: translateY(-50%); + margin-right: var(--space-1); +} + +.right { + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: var(--space-1); +} + +@keyframes tooltipIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/apps/client/src/contexts/ThemeContext.tsx b/apps/client/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..4f2e7a0 --- /dev/null +++ b/apps/client/src/contexts/ThemeContext.tsx @@ -0,0 +1,67 @@ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextValue { + readonly theme: Theme; + readonly toggleTheme: () => void; +} + +const ThemeContext = createContext(null); + +const STORAGE_KEY = 'theme-preference'; + +function getSystemTheme(): Theme { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function getInitialTheme(): Theme { + if (typeof window === 'undefined') return 'light'; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + return getSystemTheme(); +} + +interface ThemeProviderProps { + readonly children: ReactNode; +} + +export function ThemeProvider({ children }: ThemeProviderProps) { + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + setTheme(mediaQuery.matches ? 'dark' : 'light'); + } + }; + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const toggleTheme = useCallback(() => { + setTheme((prev) => (prev === 'light' ? 'dark' : 'light')); + }, []); + + return ( + + {children} + + ); +} + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} diff --git a/apps/client/src/contexts/ToastContext.tsx b/apps/client/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..e801965 --- /dev/null +++ b/apps/client/src/contexts/ToastContext.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useCallback, useState, useMemo, type ReactNode } from 'react'; +import { ToastContainer, type ToastType, type ToastItem } from '../components/ui/Toast'; + +interface ToastContextValue { + showToast(message: string, type?: ToastType, duration?: number): void; +} + +const ToastContext = createContext(null); + +let nextId = 0; + +interface ToastProviderProps { + readonly children: ReactNode; +} + +export function ToastProvider({ children }: ToastProviderProps) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback( + (message: string, type: ToastType = 'info', duration = 3000) => { + const id = String(++nextId); + const toast: ToastItem = { id, message, type, duration }; + setToasts((prev) => [...prev, toast]); + }, + [], + ); + + const dismissToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const value = useMemo(() => ({ showToast }), [showToast]); + + return ( + + {children} + + + ); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error('useToast must be used within a ToastProvider'); + } + return ctx; +} diff --git a/apps/client/src/i18n.ts b/apps/client/src/i18n.ts new file mode 100644 index 0000000..841598b --- /dev/null +++ b/apps/client/src/i18n.ts @@ -0,0 +1,38 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + supportedLngs: ['en', 'ru'], + debug: false, + interpolation: { + escapeValue: false, + }, + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'i18nextLng', + }, + resources: {}, + }); + +async function loadTranslations(lng: string): Promise { + try { + const response = await fetch(`/locales/${lng}/translation.json`); + const translations = await response.json(); + i18n.addResourceBundle(lng, 'translation', translations, true, true); + } catch { + // Silently fall back to bundled resources + } +} + +async function initTranslations(): Promise { + await Promise.all([loadTranslations('en'), loadTranslations('ru')]); +} + +export { initTranslations }; +export default i18n; diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx new file mode 100644 index 0000000..a1f6653 --- /dev/null +++ b/apps/client/src/main.tsx @@ -0,0 +1,25 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { RouterProvider } from 'react-router/dom'; +import { router } from './router'; +import { ThemeProvider } from './contexts/ThemeContext'; +import './i18n'; +import { initTranslations } from './i18n'; +import './styles/tokens.css'; +import './styles/global.css'; + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Root element not found'); +} + +initTranslations().then(() => { + createRoot(rootElement).render( + + + + + , + ); +}); diff --git a/apps/client/src/pages/ApartmentDetailPage.tsx b/apps/client/src/pages/ApartmentDetailPage.tsx new file mode 100644 index 0000000..c01647f --- /dev/null +++ b/apps/client/src/pages/ApartmentDetailPage.tsx @@ -0,0 +1,300 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import type { ApartmentWithRooms, Room, Point } from '@house-plan-maker/shared'; +import { + getApartment, + getRooms, + getRoomFull, + createRoom, + updateRoom, + deleteRoom, +} from '../api/client'; +import { + createApartmentPdf, + downloadBlob, + sanitizeFilename, +} from '../components/editor/export/exportUtils'; +import { Button } from '../components/ui/Button'; +import { LoadingSpinner } from '../components/ui/LoadingSpinner'; +import { ErrorBanner } from '../components/ui/ErrorBanner'; +import { EmptyState } from '../components/ui/EmptyState'; +import { RoomCard } from '../components/rooms/RoomCard'; +import { RoomFormModal } from '../components/rooms/RoomFormModal'; +import { ConfirmDialog } from '../components/shared/ConfirmDialog'; +import { TemplatePicker } from '../components/editor/templates/TemplatePicker'; +import type { CreateRoomDto } from '@house-plan-maker/shared'; +import styles from './apartment-detail-page.module.css'; + +export function ApartmentDetailPage() { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [apartment, setApartment] = useState(null); + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Form modal state + const [formOpen, setFormOpen] = useState(false); + const [editingRoom, setEditingRoom] = useState(null); + const [formLoading, setFormLoading] = useState(false); + + // Template picker state + const [templatePickerOpen, setTemplatePickerOpen] = useState(false); + + // Delete dialog state + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + // PDF export state + const [exportLoading, setExportLoading] = useState(false); + + const fetchData = useCallback(async () => { + if (!id) return; + try { + setError(null); + const [apt, roomList] = await Promise.all([ + getApartment(id), + getRooms(id), + ]); + setApartment(apt); + setRooms(roomList); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('rooms.error.load'); + setError(message); + } finally { + setLoading(false); + } + }, [id, t]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleCreateRoom = () => { + setEditingRoom(null); + setFormOpen(true); + }; + + const handleEditRoom = (room: Room) => { + setEditingRoom(room); + setFormOpen(true); + }; + + const handleRoomFormSubmit = async (data: { + name: string; + shape: readonly Point[]; + width: number | null; + height: number | null; + wallHeight: number; + plinthHeight: number; + plinthThickness: number; + }) => { + if (!id) return; + setFormLoading(true); + try { + const payload = { + name: data.name, + shape: data.shape.length > 0 ? [...data.shape] : undefined, + width: data.width, + height: data.height, + wallHeight: data.wallHeight, + plinthHeight: data.plinthHeight, + plinthThickness: data.plinthThickness, + }; + + if (editingRoom) { + await updateRoom(editingRoom.id, payload); + } else { + await createRoom(id, payload); + } + + setFormOpen(false); + await fetchData(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('rooms.error.operation'); + setError(message); + } finally { + setFormLoading(false); + } + }; + + const handleCreateFromTemplate = async (dto: CreateRoomDto) => { + if (!id) return; + setFormLoading(true); + try { + await createRoom(id, { + name: dto.name, + shape: dto.shape ? [...dto.shape] : undefined, + width: dto.width, + height: dto.height, + wallHeight: dto.wallHeight, + order: dto.order, + }); + await fetchData(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('rooms.error.create'); + setError(message); + } finally { + setFormLoading(false); + } + }; + + const handleDeleteClick = (room: Room) => { + setDeleteTarget(room); + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + setDeleteLoading(true); + try { + await deleteRoom(deleteTarget.id); + setDeleteTarget(null); + await fetchData(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('rooms.error.delete'); + setError(message); + } finally { + setDeleteLoading(false); + } + }; + + const handleExportPdf = useCallback(async () => { + if (!apartment || rooms.length === 0) return; + setExportLoading(true); + try { + const fullRooms = await Promise.all( + rooms.map((room) => getRoomFull(room.id)), + ); + + const pdfRooms = fullRooms.map((room) => ({ + roomName: room.name, + topDownDataUrl: null as string | null, + projectionDataUrls: [] as readonly { readonly label: string; readonly dataUrl: string }[], + })); + + const pdf = await createApartmentPdf(apartment.name, pdfRooms); + const blob = pdf.output('blob'); + const filename = `${sanitizeFilename(apartment.name)}_apartment.pdf`; + downloadBlob(blob, filename); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to export PDF'; + setError(message); + } finally { + setExportLoading(false); + } + }, [apartment, rooms]); + + if (loading) { + return ; + } + + if (!apartment || !id) { + return ( + + ); + } + + return ( +
+
+
+

{apartment.name}

+ {apartment.address && ( +

{apartment.address}

+ )} +
+ {apartment.totalArea != null && ( + + {apartment.totalArea} m² + + )} + + {t('rooms.count', { count: rooms.length })} + +
+
+
+ {rooms.length > 0 && ( + + )} + {rooms.length > 0 && ( + + )} +
+
+ + {error && setError(null)} />} + +
+

{t('rooms.title')}

+
+ + +
+
+ + {rooms.length === 0 && !error ? ( + {t('rooms.addFirst')} + } + /> + ) : ( +
+ {rooms.map((room) => ( + + ))} +
+ )} + + setFormOpen(false)} + onSubmit={handleRoomFormSubmit} + room={editingRoom} + loading={formLoading} + /> + + setDeleteTarget(null)} + onConfirm={handleDeleteConfirm} + title={t('rooms.delete.title')} + message={t('rooms.delete.message', { name: deleteTarget?.name ?? '' })} + loading={deleteLoading} + /> + + setTemplatePickerOpen(false)} + onCreateRoom={handleCreateFromTemplate} + roomCount={rooms.length} + /> +
+ ); +} diff --git a/apps/client/src/pages/ApartmentFloorPlanPage.tsx b/apps/client/src/pages/ApartmentFloorPlanPage.tsx new file mode 100644 index 0000000..727272f --- /dev/null +++ b/apps/client/src/pages/ApartmentFloorPlanPage.tsx @@ -0,0 +1,443 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { Stage, Layer, Line, Text, Group } from 'react-konva'; +import type Konva from 'konva'; +import type { Room, Point } from '@house-plan-maker/shared'; +import { getApartment, getRooms, updateRoom } from '../api/client'; +import { useEditorZoom } from '../components/editor/hooks/useEditorZoom'; +import { DEFAULT_ZOOM } from '../components/editor/types'; +import { Button } from '../components/ui/Button'; +import { LoadingSpinner } from '../components/ui/LoadingSpinner'; +import { ErrorBanner } from '../components/ui/ErrorBanner'; +import styles from './apartment-floor-plan-page.module.css'; + +/** Room fill color. */ +const ROOM_FILL = '#f8f9fa'; +/** Room stroke color. */ +const ROOM_STROKE = '#adb5bd'; +/** Selected room stroke color. */ +const ROOM_SELECTED_STROKE = '#4c6ef5'; +/** Room name label color. */ +const LABEL_COLOR = '#495057'; +/** Dimension label color. */ +const DIM_COLOR = '#868e96'; + +/** Compute the area of a polygon using the shoelace formula. */ +function computePolygonArea(shape: readonly Point[]): number { + if (shape.length < 3) return 0; + let area = 0; + for (let i = 0; i < shape.length; i++) { + const j = (i + 1) % shape.length; + area += shape[i].x * shape[j].y; + area -= shape[j].x * shape[i].y; + } + return Math.abs(area) / 2; +} + +/** Compute bounding box of a shape. */ +function shapeBounds(shape: readonly Point[]): { + minX: number; + minY: number; + maxX: number; + maxY: number; + width: number; + height: number; +} { + if (shape.length === 0) { + return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 }; + } + const xs = shape.map((p) => p.x); + const ys = shape.map((p) => p.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; +} + +/** Compute centroid of a polygon. */ +function shapeCentroid(shape: readonly Point[]): Point { + if (shape.length === 0) return { x: 0, y: 0 }; + const bounds = shapeBounds(shape); + return { + x: (bounds.minX + bounds.maxX) / 2, + y: (bounds.minY + bounds.maxY) / 2, + }; +} + +export function ApartmentFloorPlanPage() { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [apartmentName, setApartmentName] = useState(''); + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [selectedRoomId, setSelectedRoomId] = useState(null); + + const [zoom, setZoom] = useState(DEFAULT_ZOOM); + const [panOffset, setPanOffset] = useState({ x: 50, y: 50 }); + + const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); + const containerRef = useRef(null); + const stageRef = useRef(null); + + // Track which room is being dragged to avoid selecting on drag end + const isDraggingRef = useRef(false); + + const fetchData = useCallback(async () => { + if (!id) return; + try { + setError(null); + const [apt, roomList] = await Promise.all([ + getApartment(id), + getRooms(id), + ]); + setApartmentName(apt.name); + setRooms(roomList); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : t('rooms.error.load'); + setError(message); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Resize observer for the canvas container + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setStageSize({ width: Math.floor(width), height: Math.floor(height) }); + } + }); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + + const { + handleWheel, + zoomIn, + zoomOut, + resetView, + isPanningRef, + handlePanStart, + handlePanMove, + handlePanEnd, + } = useEditorZoom({ + zoom, + panOffset, + onZoomChange: setZoom, + onPanChange: setPanOffset, + }); + + const handleStageMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + handlePanStart(e); + // Left-click on empty stage => deselect + if (e.evt.button === 0 && e.target === e.target.getStage()) { + setSelectedRoomId(null); + } + }, + [handlePanStart], + ); + + const handleStageMouseMove = useCallback( + (e: Konva.KonvaEventObject) => { + handlePanMove(e); + }, + [handlePanMove], + ); + + const handleStageMouseUp = useCallback(() => { + handlePanEnd(); + }, [handlePanEnd]); + + const handleRoomClick = useCallback( + (roomId: string) => { + // Don't select if we just finished dragging + if (isDraggingRef.current) { + isDraggingRef.current = false; + return; + } + setSelectedRoomId(roomId); + }, + [], + ); + + const handleRoomDblClick = useCallback( + (roomId: string) => { + if (!id) return; + navigate(`/apartments/${id}/rooms/${roomId}/editor`); + }, + [id, navigate], + ); + + const handleRoomDragEnd = useCallback( + async (roomId: string, newPosX: number, newPosY: number) => { + isDraggingRef.current = true; + try { + const updated = await updateRoom(roomId, { + posX: newPosX, + posY: newPosY, + }); + setRooms((prev) => + prev.map((r) => (r.id === roomId ? updated : r)), + ); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : t('rooms.error.operation'); + setError(message); + // Refetch to restore correct positions + await fetchData(); + } + }, + [fetchData], + ); + + const selectedRoom = useMemo( + () => rooms.find((r) => r.id === selectedRoomId) ?? null, + [rooms, selectedRoomId], + ); + + const selectedRoomArea = useMemo(() => { + if (!selectedRoom || selectedRoom.shape.length < 3) return null; + return computePolygonArea(selectedRoom.shape); + }, [selectedRoom]); + + const selectedRoomBounds = useMemo(() => { + if (!selectedRoom || selectedRoom.shape.length < 3) return null; + return shapeBounds(selectedRoom.shape); + }, [selectedRoom]); + + const zoomPercent = Math.round((zoom / DEFAULT_ZOOM) * 100); + + const handleBackClick = useCallback(() => { + if (!id) return; + navigate(`/apartments/${id}`); + }, [id, navigate]); + + if (loading) { + return ; + } + + if (error && rooms.length === 0) { + return ; + } + + return ( +
+
+
+ + + {apartmentName} — {t('breadcrumb.floorPlan')} + +
+
+ + {zoomPercent}% + + +
+
+ + {error && setError(null)} />} + +
+ + + {rooms.map((room) => ( + + ))} + + + + {selectedRoom && ( +
+
{selectedRoom.name}
+ {selectedRoomBounds && ( +
+ + {selectedRoomBounds.width.toFixed(2)} x{' '} + {selectedRoomBounds.height.toFixed(2)} m + +
+ )} + {selectedRoomArea != null && ( +
+ {t('apartmentCard.area')} {selectedRoomArea.toFixed(2)} m² +
+ )} +
+ + {t('floorPlan.dblClickToEdit')} + +
+
+ )} +
+
+ ); +} + +// ── Individual room rendering on the floor plan ── + +interface FloorPlanRoomProps { + readonly room: Room; + readonly zoom: number; + readonly panOffset: Point; + readonly isSelected: boolean; + readonly onClick: (roomId: string) => void; + readonly onDblClick: (roomId: string) => void; + readonly onDragEnd: (roomId: string, posX: number, posY: number) => void; +} + +function FloorPlanRoom({ + room, + zoom, + panOffset, + isSelected, + onClick, + onDblClick, + onDragEnd, +}: FloorPlanRoomProps) { + const shape = room.shape; + + // If there is no shape, render a placeholder rectangle based on width/height + const effectiveShape: readonly Point[] = useMemo(() => { + if (shape.length >= 3) return shape; + const w = room.width ?? 3; + const h = room.height ?? 3; + return [ + { x: 0, y: 0 }, + { x: w, y: 0 }, + { x: w, y: h }, + { x: 0, y: h }, + ]; + }, [shape, room.width, room.height]); + + // Screen coordinates for the polygon, relative to the group origin + const screenPoints = useMemo(() => { + return effectiveShape.flatMap((p) => [p.x * zoom, p.y * zoom]); + }, [effectiveShape, zoom]); + + // Centroid for label placement (in local/zoom coords) + const centroid = useMemo(() => { + const c = shapeCentroid(effectiveShape); + return { x: c.x * zoom, y: c.y * zoom }; + }, [effectiveShape, zoom]); + + // Bounding box for dimension labels + const bounds = useMemo(() => shapeBounds(effectiveShape), [effectiveShape]); + + // Group position (room.posX/posY in world coords -> screen coords) + const groupX = room.posX * zoom + panOffset.x; + const groupY = room.posY * zoom + panOffset.y; + + const handleDragEnd = useCallback( + (e: Konva.KonvaEventObject) => { + const node = e.target; + const newScreenX = node.x(); + const newScreenY = node.y(); + // Convert back to world coords + const newPosX = (newScreenX - panOffset.x) / zoom; + const newPosY = (newScreenY - panOffset.y) / zoom; + onDragEnd(room.id, newPosX, newPosY); + }, + [room.id, zoom, panOffset, onDragEnd], + ); + + const handleClick = useCallback(() => { + onClick(room.id); + }, [room.id, onClick]); + + const handleDblClick = useCallback(() => { + onDblClick(room.id); + }, [room.id, onDblClick]); + + const dimLabel = `${bounds.width.toFixed(1)} x ${bounds.height.toFixed(1)} m`; + + return ( + + {/* Room polygon */} + + + {/* Room name label */} + + + {/* Dimension label */} + + + ); +} diff --git a/apps/client/src/pages/ApartmentListPage.tsx b/apps/client/src/pages/ApartmentListPage.tsx new file mode 100644 index 0000000..d2b0955 --- /dev/null +++ b/apps/client/src/pages/ApartmentListPage.tsx @@ -0,0 +1,162 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Apartment } from '@house-plan-maker/shared'; +import { + getApartments, + createApartment, + updateApartment, + deleteApartment, +} from '../api/client'; +import { Button } from '../components/ui/Button'; +import { LoadingSpinner } from '../components/ui/LoadingSpinner'; +import { ErrorBanner } from '../components/ui/ErrorBanner'; +import { EmptyState } from '../components/ui/EmptyState'; +import { ApartmentCard } from '../components/apartments/ApartmentCard'; +import { ApartmentFormModal } from '../components/apartments/ApartmentFormModal'; +import { ConfirmDialog } from '../components/shared/ConfirmDialog'; +import styles from './apartment-list-page.module.css'; + +export function ApartmentListPage() { + const { t } = useTranslation(); + const [apartments, setApartments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Form modal state + const [formOpen, setFormOpen] = useState(false); + const [editingApartment, setEditingApartment] = useState(null); + const [formLoading, setFormLoading] = useState(false); + + // Delete dialog state + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + const fetchApartments = useCallback(async () => { + try { + setError(null); + const list = await getApartments(); + setApartments(list); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('apartments.error.load'); + setError(message); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + fetchApartments(); + }, [fetchApartments]); + + const handleCreate = () => { + setEditingApartment(null); + setFormOpen(true); + }; + + const handleEdit = (apartment: Apartment) => { + setEditingApartment(apartment); + setFormOpen(true); + }; + + const handleFormSubmit = async (data: { + name: string; + address: string; + totalArea: string; + }) => { + setFormLoading(true); + try { + const payload = { + name: data.name, + address: data.address || null, + totalArea: data.totalArea ? Number(data.totalArea) : null, + }; + + if (editingApartment) { + await updateApartment(editingApartment.id, payload); + } else { + await createApartment(payload); + } + + setFormOpen(false); + await fetchApartments(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('apartments.error.operation'); + setError(message); + } finally { + setFormLoading(false); + } + }; + + const handleDeleteClick = (apartment: Apartment) => { + setDeleteTarget(apartment); + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + setDeleteLoading(true); + try { + await deleteApartment(deleteTarget.id); + setDeleteTarget(null); + await fetchApartments(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('apartments.error.delete'); + setError(message); + } finally { + setDeleteLoading(false); + } + }; + + if (loading) { + return ; + } + + return ( +
+
+

{t('apartments.title')}

+ +
+ + {error && setError(null)} />} + + {apartments.length === 0 && !error ? ( + {t('apartments.create')} + } + /> + ) : ( +
+ {apartments.map((apt) => ( + + ))} +
+ )} + + setFormOpen(false)} + onSubmit={handleFormSubmit} + apartment={editingApartment} + loading={formLoading} + /> + + setDeleteTarget(null)} + onConfirm={handleDeleteConfirm} + title={t('apartments.delete.title')} + message={t('apartments.delete.message', { name: deleteTarget?.name ?? '' })} + loading={deleteLoading} + /> +
+ ); +} diff --git a/apps/client/src/pages/RoomEditorPage.tsx b/apps/client/src/pages/RoomEditorPage.tsx new file mode 100644 index 0000000..52dc650 --- /dev/null +++ b/apps/client/src/pages/RoomEditorPage.tsx @@ -0,0 +1,52 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import type { RoomFull } from '@house-plan-maker/shared'; +import { getRoomFull } from '../api/client'; +import { LoadingSpinner } from '../components/ui/LoadingSpinner'; +import { ErrorBanner } from '../components/ui/ErrorBanner'; +import { EditorProvider } from '../components/editor/context/EditorContext'; +import { UndoRedoProvider } from '../components/editor/context/UndoRedoContext'; +import { RoomEditorLayout } from '../components/editor/RoomEditorLayout'; + +export function RoomEditorPage() { + const { t } = useTranslation(); + const { roomId } = useParams<{ id: string; roomId: string }>(); + const [room, setRoom] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!roomId) return; + + const fetchRoom = async () => { + try { + const fullRoom = await getRoomFull(roomId); + setRoom(fullRoom); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('editor.error.load'); + setError(message); + } finally { + setLoading(false); + } + }; + + fetchRoom(); + }, [roomId, t]); + + if (loading) { + return ; + } + + if (error || !room) { + return ; + } + + return ( + + + + + + ); +} diff --git a/apps/client/src/pages/apartment-detail-page.module.css b/apps/client/src/pages/apartment-detail-page.module.css new file mode 100644 index 0000000..cb1e371 --- /dev/null +++ b/apps/client/src/pages/apartment-detail-page.module.css @@ -0,0 +1,65 @@ +.page { + max-width: var(--content-max-width); + margin: 0 auto; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: var(--space-6); + gap: var(--space-4); +} + +.headerInfo { + min-width: 0; +} + +.title { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +.subtitle { + font-size: var(--font-size-base); + color: var(--color-text-secondary); + margin-top: var(--space-1); +} + +.meta { + display: flex; + gap: var(--space-4); + margin-top: var(--space-3); +} + +.metaBadge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + background-color: var(--color-neutral-100); + border-radius: var(--radius-full); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); + gap: var(--space-4); +} + +.sectionTitle { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.roomList { + display: flex; + flex-direction: column; + gap: var(--space-3); +} diff --git a/apps/client/src/pages/apartment-floor-plan-page.module.css b/apps/client/src/pages/apartment-floor-plan-page.module.css new file mode 100644 index 0000000..7b90857 --- /dev/null +++ b/apps/client/src/pages/apartment-floor-plan-page.module.css @@ -0,0 +1,73 @@ +.container { + display: flex; + flex-direction: column; + height: calc(100vh - 64px); + overflow: hidden; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-4); + background-color: var(--color-neutral-50); + border-bottom: 1px solid var(--color-neutral-200); + flex-shrink: 0; +} + +.toolbarLeft { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.toolbarRight { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.title { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.canvasWrapper { + flex: 1; + position: relative; + overflow: hidden; +} + +.roomInfo { + position: absolute; + bottom: var(--space-4); + left: var(--space-4); + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + pointer-events: none; + max-width: 280px; +} + +.roomInfoTitle { + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin-bottom: var(--space-1); +} + +.roomInfoRow { + display: flex; + justify-content: space-between; + gap: var(--space-4); +} + +.zoomDisplay { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + min-width: 48px; + text-align: center; +} diff --git a/apps/client/src/pages/apartment-list-page.module.css b/apps/client/src/pages/apartment-list-page.module.css new file mode 100644 index 0000000..a793ca1 --- /dev/null +++ b/apps/client/src/pages/apartment-list-page.module.css @@ -0,0 +1,36 @@ +.page { + max-width: var(--content-max-width); + margin: 0 auto; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-6); + gap: var(--space-4); +} + +.title { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +.grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); +} + +@media (min-width: 640px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1024px) { + .grid { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/apps/client/src/pages/room-editor-page.module.css b/apps/client/src/pages/room-editor-page.module.css new file mode 100644 index 0000000..76fda1d --- /dev/null +++ b/apps/client/src/pages/room-editor-page.module.css @@ -0,0 +1,8 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-12) var(--space-6); + text-align: center; +} diff --git a/apps/client/src/router.tsx b/apps/client/src/router.tsx new file mode 100644 index 0000000..15e9114 --- /dev/null +++ b/apps/client/src/router.tsx @@ -0,0 +1,40 @@ +import { createBrowserRouter } from 'react-router'; +import { AppShell } from './components/layout/AppShell'; +import { ApartmentListPage } from './pages/ApartmentListPage'; +import { ApartmentDetailPage } from './pages/ApartmentDetailPage'; +import { ApartmentFloorPlanPage } from './pages/ApartmentFloorPlanPage'; +import { RoomEditorPage } from './pages/RoomEditorPage'; + +export const router = createBrowserRouter([ + { + path: '/', + element: , + handle: { crumb: 'breadcrumb.apartments' }, + children: [ + { + index: true, + element: , + }, + { + path: 'apartments/:id', + handle: { crumb: 'breadcrumb.apartmentDetails' }, + children: [ + { + index: true, + element: , + }, + { + path: 'floorplan', + handle: { crumb: 'breadcrumb.floorPlan' }, + element: , + }, + { + path: 'rooms/:roomId/editor', + handle: { crumb: 'breadcrumb.roomEditor' }, + element: , + }, + ], + }, + ], + }, +]); diff --git a/apps/client/src/styles/global.css b/apps/client/src/styles/global.css new file mode 100644 index 0000000..8d58619 --- /dev/null +++ b/apps/client/src/styles/global.css @@ -0,0 +1,108 @@ +/* ═══════════════════════════════════════════════════════════════ + Global Styles — House Plan Maker + ═══════════════════════════════════════════════════════════════ */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +/* ── Reset ── */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-base); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-normal); + color: var(--color-text-primary); + background-color: var(--color-bg); + min-height: 100vh; +} + +h1, h2, h3, h4, h5, h6 { + line-height: var(--line-height-tight); + letter-spacing: var(--letter-spacing-tight); + font-weight: var(--font-weight-semibold); +} + +h1 { font-size: var(--font-size-3xl); } +h2 { font-size: var(--font-size-2xl); } +h3 { font-size: var(--font-size-xl); } +h4 { font-size: var(--font-size-lg); } + +a { + color: var(--color-accent-600); + text-decoration: none; +} + +a:hover { + color: var(--color-accent-700); + text-decoration: underline; +} + +button { + font-family: inherit; + cursor: pointer; +} + +input, select, textarea { + font-family: inherit; + font-size: inherit; +} + +img, svg { + display: block; + max-width: 100%; +} + +/* ── Focus styles ── */ + +:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +/* ── Scrollbar ── */ + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-neutral-300); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-neutral-400); +} + +/* ── Utility: screen reader only ── */ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/apps/client/src/styles/tokens.css b/apps/client/src/styles/tokens.css new file mode 100644 index 0000000..719b630 --- /dev/null +++ b/apps/client/src/styles/tokens.css @@ -0,0 +1,193 @@ +/* ═══════════════════════════════════════════════════════════════ + Design Tokens — House Plan Maker + Professional tool for electricians, furniture makers, and + interior designers. Clean, precise, trustworthy. + ═══════════════════════════════════════════════════════════════ */ + +:root { + /* ── Color: Neutral ── */ + --color-neutral-0: #ffffff; + --color-neutral-50: #f8f9fa; + --color-neutral-100: #f1f3f5; + --color-neutral-200: #e9ecef; + --color-neutral-300: #dee2e6; + --color-neutral-400: #ced4da; + --color-neutral-500: #adb5bd; + --color-neutral-600: #868e96; + --color-neutral-700: #495057; + --color-neutral-800: #343a40; + --color-neutral-900: #212529; + + /* ── Color: Accent (Indigo — professional, trustworthy) ── */ + --color-accent-50: #edf2ff; + --color-accent-100: #dbe4ff; + --color-accent-200: #bac8ff; + --color-accent-300: #91a7ff; + --color-accent-400: #748ffc; + --color-accent-500: #5c7cfa; + --color-accent-600: #4c6ef5; + --color-accent-700: #4263eb; + --color-accent-800: #3b5bdb; + --color-accent-900: #364fc7; + + /* ── Color: Success ── */ + --color-success-50: #ebfbee; + --color-success-500: #40c057; + --color-success-700: #2f9e44; + + /* ── Color: Warning ── */ + --color-warning-50: #fff9db; + --color-warning-500: #fab005; + --color-warning-700: #e67700; + + /* ── Color: Danger ── */ + --color-danger-50: #fff5f5; + --color-danger-100: #ffe3e3; + --color-danger-500: #fa5252; + --color-danger-600: #e03131; + --color-danger-700: #c92a2a; + + /* ── Semantic Colors ── */ + --color-bg: var(--color-neutral-50); + --color-bg-elevated: var(--color-neutral-0); + --color-bg-hover: var(--color-neutral-100); + --color-text-primary: var(--color-neutral-900); + --color-text-secondary: var(--color-neutral-600); + --color-text-muted: var(--color-neutral-500); + --color-text-on-accent: var(--color-neutral-0); + --color-border: var(--color-neutral-200); + --color-border-strong: var(--color-neutral-300); + --color-focus-ring: var(--color-accent-300); + + /* ── Typography ── */ + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', + Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + + --font-size-xs: 0.75rem; /* 12px */ + --font-size-sm: 0.8125rem; /* 13px */ + --font-size-base: 0.875rem; /* 14px */ + --font-size-md: 1rem; /* 16px */ + --font-size-lg: 1.125rem; /* 18px */ + --font-size-xl: 1.25rem; /* 20px */ + --font-size-2xl: 1.5rem; /* 24px */ + --font-size-3xl: 1.875rem; /* 30px */ + + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --line-height-tight: 1.25; + --line-height-normal: 1.5; + --line-height-relaxed: 1.625; + + --letter-spacing-tight: -0.01em; + --letter-spacing-normal: 0; + --letter-spacing-wide: 0.025em; + + /* ── Spacing ── */ + --space-0: 0; + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + + /* ── Border Radius ── */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-full: 9999px; + + /* ── Shadows ── */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.08), 0 8px 10px rgba(0, 0, 0, 0.04); + + /* ── Transitions ── */ + --transition-fast: 120ms ease; + --transition-normal: 160ms ease; + --transition-base: 200ms ease; + --transition-slow: 300ms ease; + + /* ── Z-Index ── */ + --z-dropdown: 100; + --z-sticky: 200; + --z-modal-backdrop: 300; + --z-modal: 400; + --z-toast: 500; + + /* ── Layout ── */ + --sidebar-width: 240px; + --header-height: 56px; + --content-max-width: 1200px; +} + +/* ═══════════════════════════════════════════════════════════════ + Dark Theme + ═══════════════════════════════════════════════════════════════ */ + +[data-theme="dark"] { + /* ── Semantic Colors ── */ + --color-bg: #1a1b1e; + --color-bg-elevated: #25262b; + --color-bg-hover: #2c2e33; + --color-text-primary: #c1c2c5; + --color-text-secondary: #909296; + --color-text-muted: #5c5f66; + --color-text-on-accent: #ffffff; + --color-border: #373a40; + --color-border-strong: #4a4d54; + --color-focus-ring: var(--color-accent-600); + + /* ── Neutral overrides for dark ── */ + --color-neutral-0: #1a1b1e; + --color-neutral-50: #25262b; + --color-neutral-100: #2c2e33; + --color-neutral-200: #373a40; + --color-neutral-300: #4a4d54; + --color-neutral-400: #5c5f66; + --color-neutral-500: #71747a; + --color-neutral-600: #909296; + --color-neutral-700: #c1c2c5; + --color-neutral-800: #d5d7da; + --color-neutral-900: #e9ecef; + + /* ── Accent overrides for dark ── */ + --color-accent-50: #1b2332; + --color-accent-100: #1f2b42; + --color-accent-200: #2b3d5e; + --color-accent-700: #748ffc; + --color-accent-800: #91a7ff; + + /* ── Status colors for dark ── */ + --color-success-50: #0d2818; + --color-success-500: #51cf66; + --color-success-700: #69db7c; + + --color-warning-50: #2a2000; + --color-warning-500: #fcc419; + --color-warning-700: #ffe066; + + --color-danger-50: #2c1111; + --color-danger-100: #3d1515; + --color-danger-500: #ff6b6b; + --color-danger-600: #fa5252; + --color-danger-700: #ff8787; + + /* ── Shadows for dark ── */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3), 0 4px 6px rgba(0, 0, 0, 0.2); + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4), 0 8px 10px rgba(0, 0, 0, 0.2); +} diff --git a/apps/client/src/utils/format.ts b/apps/client/src/utils/format.ts new file mode 100644 index 0000000..eeefffd --- /dev/null +++ b/apps/client/src/utils/format.ts @@ -0,0 +1,9 @@ +/** + * Format a dimension value with locale-aware decimal separator. + */ +export function formatDimension(value: number, locale: string): string { + return new Intl.NumberFormat(locale, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(value); +} diff --git a/apps/client/src/vite-env.d.ts b/apps/client/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json new file mode 100644 index 0000000..a308256 --- /dev/null +++ b/apps/client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "references": [ + { "path": "../../packages/shared" } + ] +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts new file mode 100644 index 0000000..5e2ffeb --- /dev/null +++ b/apps/client/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: '0.0.0.0', + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + }, +}); diff --git a/apps/client/vitest.config.ts b/apps/client/vitest.config.ts new file mode 100644 index 0000000..d4634fd --- /dev/null +++ b/apps/client/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + passWithNoTests: true, + environment: 'jsdom', + }, +}); diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 0000000..f64572b --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,6 @@ +# Server configuration +PORT=3001 +HOST=0.0.0.0 + +# Database +DATABASE_URL="file:./dev.db" diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..f603a6a --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,26 @@ +{ + "name": "@house-plan-maker/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc --build", + "start": "node dist/index.js", + "test": "vitest run", + "lint": "eslint src/" + }, + "dependencies": { + "@house-plan-maker/shared": "*", + "@prisma/client": "^6.3.0", + "fastify": "^5.2.0", + "fastify-plugin": "^5.1.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "prisma": "^6.3.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/apps/server/prisma/migrations/20260405144549_add_room_position/migration.sql b/apps/server/prisma/migrations/20260405144549_add_room_position/migration.sql new file mode 100644 index 0000000..03329aa --- /dev/null +++ b/apps/server/prisma/migrations/20260405144549_add_room_position/migration.sql @@ -0,0 +1,102 @@ +-- CreateTable +CREATE TABLE "Apartment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "address" TEXT, + "totalArea" REAL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "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, + "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 +); + +-- CreateTable +CREATE TABLE "Wall" ( + "id" TEXT NOT NULL PRIMARY KEY, + "roomId" TEXT NOT NULL, + "startX" REAL NOT NULL, + "startY" REAL NOT NULL, + "endX" REAL NOT NULL, + "endY" REAL NOT NULL, + "thickness" REAL NOT NULL DEFAULT 0.1, + "direction" TEXT NOT NULL DEFAULT 'OTHER', + CONSTRAINT "Wall_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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, + 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 +); + +-- CreateTable +CREATE TABLE "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, + "metadata" TEXT, + CONSTRAINT "ElectricalItem_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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, + "label" TEXT, + CONSTRAINT "FurnitureItem_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "Room_apartmentId_idx" ON "Room"("apartmentId"); + +-- CreateIndex +CREATE INDEX "Wall_roomId_idx" ON "Wall"("roomId"); + +-- CreateIndex +CREATE INDEX "WallOpening_roomId_idx" ON "WallOpening"("roomId"); + +-- CreateIndex +CREATE INDEX "WallOpening_wallId_idx" ON "WallOpening"("wallId"); + +-- CreateIndex +CREATE INDEX "ElectricalItem_roomId_idx" ON "ElectricalItem"("roomId"); + +-- CreateIndex +CREATE INDEX "FurnitureItem_roomId_idx" ON "FurnitureItem"("roomId"); diff --git a/apps/server/prisma/migrations/migration_lock.toml b/apps/server/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/apps/server/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma new file mode 100644 index 0000000..b958b75 --- /dev/null +++ b/apps/server/prisma/schema.prisma @@ -0,0 +1,108 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model Apartment { + id String @id @default(cuid()) + name String + address String? + totalArea Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + rooms Room[] +} + +model Room { + id String @id @default(cuid()) + apartmentId String + name String + shape String @default("[]") // JSON array of {x, y} points + width Float? + height Float? + wallHeight Float @default(2.7) + plinthHeight Float @default(0.06) + plinthThickness Float @default(0.01) + order Int @default(0) + posX Float @default(0) + posY Float @default(0) + floorType String @default("CONCRETE") + wallColor String @default("#f5f0eb") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + apartment Apartment @relation(fields: [apartmentId], references: [id], onDelete: Cascade) + walls Wall[] + openings WallOpening[] + electricalItems ElectricalItem[] + furnitureItems FurnitureItem[] + + @@index([apartmentId]) +} + +model Wall { + id String @id @default(cuid()) + roomId String + startX Float + startY Float + endX Float + endY Float + thickness Float @default(0.1) + direction String @default("OTHER") // NORTH, SOUTH, EAST, WEST, OTHER + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + openings WallOpening[] + + @@index([roomId]) +} + +model WallOpening { + id String @id @default(cuid()) + roomId String + wallId String + type String // DOOR, WINDOW + positionAlongWall Float + width Float + height Float + elevationFromFloor Float @default(0) + openDirection String @default("LEFT") // LEFT, RIGHT, INWARD, OUTWARD + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + wall Wall @relation(fields: [wallId], references: [id], onDelete: Cascade) + + @@index([roomId]) + @@index([wallId]) +} + +model ElectricalItem { + id String @id @default(cuid()) + roomId String + type String // OUTLET, SWITCH, JUNCTION_BOX, LIGHT_CEILING, LIGHT_WALL, CABLE_ROUTE + x Float + y Float + wallId String? + elevationFromFloor Float? + rotation Float @default(0) + metadata String? // JSON + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + + @@index([roomId]) +} + +model FurnitureItem { + id String @id @default(cuid()) + roomId String + type String // BED, DESK, WARDROBE, SOFA, TABLE, CHAIR, SHELF, NIGHTSTAND, DRESSER, BOOKCASE, OTHER + x Float + y Float + width Float + depth Float + height Float + rotation Float @default(0) + elevationFromFloor Float @default(0) + label String? + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + + @@index([roomId]) +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..a0d3530 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,42 @@ +import Fastify from 'fastify'; +import type { HealthCheckResponse } from '@house-plan-maker/shared'; +import prismaPlugin from './plugins/prisma.js'; +import errorHandlerPlugin from './plugins/error-handler.js'; +import apartmentRoutes from './routes/apartments.js'; +import roomRoutes from './routes/rooms.js'; +import elementRoutes from './routes/elements.js'; + +const PORT = parseInt(process.env.PORT ?? '3001', 10); +const HOST = process.env.HOST ?? '0.0.0.0'; + +const server = Fastify({ + logger: true, +}); + +// Plugins +server.register(prismaPlugin); +server.register(errorHandlerPlugin); + +// Routes +server.register(apartmentRoutes); +server.register(roomRoutes); +server.register(elementRoutes); + +server.get('/api/health', async (_request, _reply): Promise => { + return { + status: 'ok', + timestamp: new Date().toISOString(), + }; +}); + +async function start() { + try { + await server.listen({ port: PORT, host: HOST }); + server.log.info(`Server listening on http://${HOST}:${PORT}`); + } catch (err) { + server.log.error(err); + process.exit(1); + } +} + +start(); diff --git a/apps/server/src/plugins/error-handler.ts b/apps/server/src/plugins/error-handler.ts new file mode 100644 index 0000000..06cffe4 --- /dev/null +++ b/apps/server/src/plugins/error-handler.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; +import { ZodError } from 'zod'; + +interface ErrorResponseBody { + readonly error: string; + readonly statusCode: number; +} + +const errorHandlerPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => { + fastify.setErrorHandler((error: unknown, _request, reply) => { + const fastifyError = error as { statusCode?: number; message?: string }; + const statusCode = fastifyError.statusCode ?? 500; + + if (error instanceof ZodError) { + const message = error.issues + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join('; '); + + const body: ErrorResponseBody = { + error: `Validation error: ${message}`, + statusCode: 400, + }; + + return reply.status(400).send(body); + } + + const body: ErrorResponseBody = { + error: statusCode >= 500 ? 'Internal Server Error' : (fastifyError.message ?? 'Unknown error'), + statusCode, + }; + + if (statusCode >= 500) { + fastify.log.error(error); + } + + return reply.status(statusCode).send(body); + }); + + fastify.setNotFoundHandler((_request, reply) => { + const body: ErrorResponseBody = { + error: 'Not Found', + statusCode: 404, + }; + return reply.status(404).send(body); + }); +}; + +export default fp(errorHandlerPlugin, { name: 'error-handler' }); diff --git a/apps/server/src/plugins/prisma.ts b/apps/server/src/plugins/prisma.ts new file mode 100644 index 0000000..68b1dd3 --- /dev/null +++ b/apps/server/src/plugins/prisma.ts @@ -0,0 +1,23 @@ +import { PrismaClient } from '@prisma/client'; +import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; + +declare module 'fastify' { + interface FastifyInstance { + prisma: PrismaClient; + } +} + +const prismaPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => { + const prisma = new PrismaClient(); + + await prisma.$connect(); + + fastify.decorate('prisma', prisma); + + fastify.addHook('onClose', async () => { + await prisma.$disconnect(); + }); +}; + +export default fp(prismaPlugin, { name: 'prisma' }); diff --git a/apps/server/src/routes/apartments.ts b/apps/server/src/routes/apartments.ts new file mode 100644 index 0000000..103dec2 --- /dev/null +++ b/apps/server/src/routes/apartments.ts @@ -0,0 +1,140 @@ +import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import { + createApartmentSchema, + updateApartmentSchema, +} from '@house-plan-maker/shared'; +import type { + Apartment, + ApartmentWithRooms, + ApiResponse, + ApiListResponse, +} from '@house-plan-maker/shared'; + +const apartmentRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { + const { prisma } = fastify; + + // GET /api/apartments + fastify.get('/api/apartments', async (): Promise> => { + const apartments = await prisma.apartment.findMany({ + orderBy: { updatedAt: 'desc' }, + include: { _count: { select: { rooms: true } } }, + }); + + return { + data: apartments.map((apt) => ({ + ...toApartmentResponse(apt), + roomCount: apt._count.rooms, + })), + }; + }); + + // POST /api/apartments + fastify.post('/api/apartments', async (request, reply): Promise> => { + const input = createApartmentSchema.parse(request.body); + + const apartment = await prisma.apartment.create({ + data: { + name: input.name, + address: input.address ?? null, + totalArea: input.totalArea ?? null, + }, + }); + + reply.status(201); + return { data: toApartmentResponse(apartment) }; + }); + + // GET /api/apartments/:id + fastify.get<{ Params: { id: string } }>( + '/api/apartments/:id', + async (request, reply): Promise> => { + const apartment = await prisma.apartment.findUnique({ + where: { id: request.params.id }, + include: { + rooms: { + select: { id: true, name: true, order: true }, + orderBy: { order: 'asc' }, + }, + }, + }); + + if (!apartment) { + return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 }); + } + + return { + data: { + ...toApartmentResponse(apartment), + rooms: apartment.rooms, + }, + }; + }, + ); + + // PUT /api/apartments/:id + fastify.put<{ Params: { id: string } }>( + '/api/apartments/:id', + async (request, reply): Promise> => { + const input = updateApartmentSchema.parse(request.body); + + const existing = await prisma.apartment.findUnique({ + where: { id: request.params.id }, + }); + + if (!existing) { + return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 }); + } + + const apartment = await prisma.apartment.update({ + where: { id: request.params.id }, + data: { + ...(input.name !== undefined && { name: input.name }), + ...(input.address !== undefined && { address: input.address ?? null }), + ...(input.totalArea !== undefined && { totalArea: input.totalArea ?? null }), + }, + }); + + return { data: toApartmentResponse(apartment) }; + }, + ); + + // DELETE /api/apartments/:id + fastify.delete<{ Params: { id: string } }>( + '/api/apartments/:id', + async (request, reply): Promise => { + const existing = await prisma.apartment.findUnique({ + where: { id: request.params.id }, + }); + + if (!existing) { + return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 }); + } + + await prisma.apartment.delete({ + where: { id: request.params.id }, + }); + + reply.status(204); + }, + ); +}; + +function toApartmentResponse(apartment: { + id: string; + name: string; + address: string | null; + totalArea: number | null; + createdAt: Date; + updatedAt: Date; +}): Apartment { + return { + id: apartment.id, + name: apartment.name, + address: apartment.address, + totalArea: apartment.totalArea, + createdAt: apartment.createdAt.toISOString(), + updatedAt: apartment.updatedAt.toISOString(), + }; +} + +export default apartmentRoutes; diff --git a/apps/server/src/routes/elements.ts b/apps/server/src/routes/elements.ts new file mode 100644 index 0000000..8a85493 --- /dev/null +++ b/apps/server/src/routes/elements.ts @@ -0,0 +1,564 @@ +import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import { + bulkUpdateWallsSchema, + createWallOpeningSchema, + updateWallOpeningSchema, + createElectricalItemSchema, + updateElectricalItemSchema, + createFurnitureItemSchema, + updateFurnitureItemSchema, + batchSyncOpeningsSchema, + batchSyncElectricalSchema, + batchSyncFurnitureSchema, +} from '@house-plan-maker/shared'; +import type { + Wall, + WallOpening, + ElectricalItem, + FurnitureItem, + ApiResponse, + ApiListResponse, +} from '@house-plan-maker/shared'; +import { + toWallResponse, + toOpeningResponse, + toElectricalResponse, + toFurnitureResponse, +} from '../utils/mappers.js'; + +const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { + const { prisma } = fastify; + + // ── Walls ── + + // PUT /api/rooms/:id/walls — bulk replace all walls in a room + fastify.put<{ Params: { id: string } }>( + '/api/rooms/:id/walls', + async (request, reply): Promise> => { + const input = bulkUpdateWallsSchema.parse(request.body); + + const room = await prisma.room.findUnique({ + where: { id: request.params.id }, + }); + + if (!room) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + const walls = await prisma.$transaction(async (tx) => { + await tx.wall.deleteMany({ where: { roomId: request.params.id } }); + + const created = await Promise.all( + input.walls.map((w) => + tx.wall.create({ + data: { + roomId: request.params.id, + startX: w.startX, + startY: w.startY, + endX: w.endX, + endY: w.endY, + thickness: w.thickness ?? 0.1, + direction: w.direction ?? 'OTHER', + }, + }), + ), + ); + + return created; + }); + + return { data: walls.map(toWallResponse) }; + }, + ); + + // ── Wall Openings ── + + // POST /api/rooms/:id/openings + fastify.post<{ Params: { id: string } }>( + '/api/rooms/:id/openings', + async (request, reply): Promise> => { + const input = createWallOpeningSchema.parse(request.body); + + const room = await prisma.room.findUnique({ + where: { id: request.params.id }, + }); + + if (!room) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + const wall = await prisma.wall.findUnique({ + where: { id: input.wallId }, + }); + + if (!wall || wall.roomId !== request.params.id) { + return reply.status(400).send({ error: 'Wall not found in this room', statusCode: 400 }); + } + + const opening = await prisma.wallOpening.create({ + data: { + roomId: request.params.id, + wallId: input.wallId, + type: input.type, + positionAlongWall: input.positionAlongWall, + width: input.width, + height: input.height, + elevationFromFloor: input.elevationFromFloor ?? 0, + openDirection: input.openDirection ?? 'LEFT', + }, + }); + + reply.status(201); + return { data: toOpeningResponse(opening) }; + }, + ); + + // PUT /api/rooms/:id/openings/:openingId + fastify.put<{ Params: { id: string; openingId: string } }>( + '/api/rooms/:id/openings/:openingId', + async (request, reply): Promise> => { + const input = updateWallOpeningSchema.parse(request.body); + + const existing = await prisma.wallOpening.findUnique({ + where: { id: request.params.openingId }, + }); + + if (!existing || existing.roomId !== request.params.id) { + return reply.status(404).send({ error: 'Opening not found', statusCode: 404 }); + } + + const opening = await prisma.wallOpening.update({ + where: { id: request.params.openingId }, + data: { + ...(input.type !== undefined && { type: input.type }), + ...(input.positionAlongWall !== undefined && { positionAlongWall: input.positionAlongWall }), + ...(input.width !== undefined && { width: input.width }), + ...(input.height !== undefined && { height: input.height }), + ...(input.elevationFromFloor !== undefined && { elevationFromFloor: input.elevationFromFloor }), + ...(input.openDirection !== undefined && { openDirection: input.openDirection }), + }, + }); + + return { data: toOpeningResponse(opening) }; + }, + ); + + // DELETE /api/rooms/:id/openings/:openingId + fastify.delete<{ Params: { id: string; openingId: string } }>( + '/api/rooms/:id/openings/:openingId', + async (request, reply): Promise => { + const existing = await prisma.wallOpening.findUnique({ + where: { id: request.params.openingId }, + }); + + if (!existing || existing.roomId !== request.params.id) { + return reply.status(404).send({ error: 'Opening not found', statusCode: 404 }); + } + + await prisma.wallOpening.delete({ + where: { id: request.params.openingId }, + }); + + reply.status(204); + }, + ); + + // ── Electrical Items ── + + // GET /api/rooms/:id/electrical + fastify.get<{ Params: { id: string } }>( + '/api/rooms/:id/electrical', + async (request, reply): Promise> => { + const room = await prisma.room.findUnique({ + where: { id: request.params.id }, + }); + + if (!room) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + const items = await prisma.electricalItem.findMany({ + where: { roomId: request.params.id }, + }); + + return { data: items.map(toElectricalResponse) }; + }, + ); + + // POST /api/rooms/:id/electrical + fastify.post<{ Params: { id: string } }>( + '/api/rooms/:id/electrical', + async (request, reply): Promise> => { + const input = createElectricalItemSchema.parse(request.body); + + const room = await prisma.room.findUnique({ + where: { id: request.params.id }, + }); + + if (!room) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + const item = await prisma.electricalItem.create({ + data: { + roomId: request.params.id, + type: input.type, + x: input.x, + y: input.y, + wallId: input.wallId ?? null, + elevationFromFloor: input.elevationFromFloor ?? null, + rotation: input.rotation ?? 0, + metadata: input.metadata ? JSON.stringify(input.metadata) : null, + }, + }); + + reply.status(201); + return { data: toElectricalResponse(item) }; + }, + ); + + // PUT /api/electrical/:id + fastify.put<{ Params: { id: string } }>( + '/api/electrical/:id', + async (request, reply): Promise> => { + const input = updateElectricalItemSchema.parse(request.body); + + const existing = await prisma.electricalItem.findUnique({ + where: { id: request.params.id }, + }); + + if (!existing) { + return reply.status(404).send({ error: 'Electrical item not found', statusCode: 404 }); + } + + const item = await prisma.electricalItem.update({ + where: { id: request.params.id }, + data: { + ...(input.type !== undefined && { type: input.type }), + ...(input.x !== undefined && { x: input.x }), + ...(input.y !== undefined && { y: input.y }), + ...(input.wallId !== undefined && { wallId: input.wallId ?? null }), + ...(input.elevationFromFloor !== undefined && { elevationFromFloor: input.elevationFromFloor ?? null }), + ...(input.rotation !== undefined && { rotation: input.rotation }), + ...(input.metadata !== undefined && { + metadata: input.metadata ? JSON.stringify(input.metadata) : null, + }), + }, + }); + + return { data: toElectricalResponse(item) }; + }, + ); + + // DELETE /api/electrical/:id + fastify.delete<{ Params: { id: string } }>( + '/api/electrical/:id', + async (request, reply): Promise => { + const existing = await prisma.electricalItem.findUnique({ + where: { id: request.params.id }, + }); + + if (!existing) { + return reply.status(404).send({ error: 'Electrical item not found', statusCode: 404 }); + } + + await prisma.electricalItem.delete({ + where: { id: request.params.id }, + }); + + reply.status(204); + }, + ); + + // ── Furniture Items ── + + // GET /api/rooms/:id/furniture + fastify.get<{ Params: { id: string } }>( + '/api/rooms/:id/furniture', + async (request, reply): Promise> => { + const room = await prisma.room.findUnique({ + where: { id: request.params.id }, + }); + + if (!room) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + const items = await prisma.furnitureItem.findMany({ + where: { roomId: request.params.id }, + }); + + return { data: items.map(toFurnitureResponse) }; + }, + ); + + // POST /api/rooms/:id/furniture + fastify.post<{ Params: { id: string } }>( + '/api/rooms/:id/furniture', + async (request, reply): Promise> => { + const input = createFurnitureItemSchema.parse(request.body); + + const room = await prisma.room.findUnique({ + where: { id: request.params.id }, + }); + + if (!room) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + const item = await prisma.furnitureItem.create({ + data: { + roomId: request.params.id, + type: input.type, + x: input.x, + y: input.y, + width: input.width, + depth: input.depth, + height: input.height, + rotation: input.rotation ?? 0, + elevationFromFloor: input.elevationFromFloor ?? 0, + label: input.label ?? null, + }, + }); + + reply.status(201); + return { data: toFurnitureResponse(item) }; + }, + ); + + // PUT /api/furniture/:id + fastify.put<{ Params: { id: string } }>( + '/api/furniture/:id', + async (request, reply): Promise> => { + const input = updateFurnitureItemSchema.parse(request.body); + + const existing = await prisma.furnitureItem.findUnique({ + where: { id: request.params.id }, + }); + + if (!existing) { + return reply.status(404).send({ error: 'Furniture item not found', statusCode: 404 }); + } + + const item = await prisma.furnitureItem.update({ + where: { id: request.params.id }, + data: { + ...(input.type !== undefined && { type: input.type }), + ...(input.x !== undefined && { x: input.x }), + ...(input.y !== undefined && { y: input.y }), + ...(input.width !== undefined && { width: input.width }), + ...(input.depth !== undefined && { depth: input.depth }), + ...(input.height !== undefined && { height: input.height }), + ...(input.rotation !== undefined && { rotation: input.rotation }), + ...(input.elevationFromFloor !== undefined && { elevationFromFloor: input.elevationFromFloor }), + ...(input.label !== undefined && { label: input.label ?? null }), + }, + }); + + return { data: toFurnitureResponse(item) }; + }, + ); + + // DELETE /api/furniture/:id + fastify.delete<{ Params: { id: string } }>( + '/api/furniture/:id', + async (request, reply): Promise => { + const existing = await prisma.furnitureItem.findUnique({ + where: { id: request.params.id }, + }); + + if (!existing) { + return reply.status(404).send({ error: 'Furniture item not found', statusCode: 404 }); + } + + await prisma.furnitureItem.delete({ + where: { id: request.params.id }, + }); + + reply.status(204); + }, + ); + // ── Batch Sync: Openings ── + + fastify.put<{ Params: { id: string } }>( + '/api/rooms/:id/openings/batch', + async (request, reply): Promise> => { + const input = batchSyncOpeningsSchema.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 }); + } + + await prisma.$transaction(async (tx) => { + // Deletes + if (input.delete.length > 0) { + await tx.wallOpening.deleteMany({ + where: { id: { in: input.delete }, roomId }, + }); + } + + // Updates + for (const item of input.update) { + await tx.wallOpening.update({ + where: { id: item.id }, + data: { + ...(item.data.type !== undefined && { type: item.data.type }), + ...(item.data.positionAlongWall !== undefined && { positionAlongWall: item.data.positionAlongWall }), + ...(item.data.width !== undefined && { width: item.data.width }), + ...(item.data.height !== undefined && { height: item.data.height }), + ...(item.data.elevationFromFloor !== undefined && { elevationFromFloor: item.data.elevationFromFloor }), + ...(item.data.openDirection !== undefined && { openDirection: item.data.openDirection }), + }, + }); + } + + // Creates + for (const item of input.create) { + await tx.wallOpening.create({ + data: { + roomId, + wallId: item.wallId, + type: item.type, + positionAlongWall: item.positionAlongWall, + width: item.width, + height: item.height, + elevationFromFloor: item.elevationFromFloor ?? 0, + openDirection: item.openDirection ?? 'LEFT', + }, + }); + } + }); + + const allOpenings = await prisma.wallOpening.findMany({ where: { roomId } }); + return { data: allOpenings.map(toOpeningResponse) }; + }, + ); + + // ── Batch Sync: Electrical ── + + fastify.put<{ Params: { id: string } }>( + '/api/rooms/:id/electrical/batch', + async (request, reply): Promise> => { + const input = batchSyncElectricalSchema.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 }); + } + + await prisma.$transaction(async (tx) => { + // Deletes + if (input.delete.length > 0) { + await tx.electricalItem.deleteMany({ + where: { id: { in: input.delete }, roomId }, + }); + } + + // Updates + for (const item of input.update) { + await tx.electricalItem.update({ + where: { id: item.id }, + data: { + ...(item.data.type !== undefined && { type: item.data.type }), + ...(item.data.x !== undefined && { x: item.data.x }), + ...(item.data.y !== undefined && { y: item.data.y }), + ...(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.metadata !== undefined && { + metadata: item.data.metadata ? JSON.stringify(item.data.metadata) : null, + }), + }, + }); + } + + // Creates + for (const item of input.create) { + await tx.electricalItem.create({ + data: { + roomId, + type: item.type, + x: item.x, + y: item.y, + wallId: item.wallId ?? null, + elevationFromFloor: item.elevationFromFloor ?? null, + rotation: item.rotation ?? 0, + metadata: item.metadata ? JSON.stringify(item.metadata) : null, + }, + }); + } + }); + + const allItems = await prisma.electricalItem.findMany({ where: { roomId } }); + return { data: allItems.map(toElectricalResponse) }; + }, + ); + + // ── Batch Sync: Furniture ── + + fastify.put<{ Params: { id: string } }>( + '/api/rooms/:id/furniture/batch', + async (request, reply): Promise> => { + const input = batchSyncFurnitureSchema.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 }); + } + + await prisma.$transaction(async (tx) => { + // Deletes + if (input.delete.length > 0) { + await tx.furnitureItem.deleteMany({ + where: { id: { in: input.delete }, roomId }, + }); + } + + // Updates + for (const item of input.update) { + await tx.furnitureItem.update({ + where: { id: item.id }, + data: { + ...(item.data.type !== undefined && { type: item.data.type }), + ...(item.data.x !== undefined && { x: item.data.x }), + ...(item.data.y !== undefined && { y: item.data.y }), + ...(item.data.width !== undefined && { width: item.data.width }), + ...(item.data.depth !== undefined && { depth: item.data.depth }), + ...(item.data.height !== undefined && { height: item.data.height }), + ...(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 }), + }, + }); + } + + // Creates + for (const item of input.create) { + await tx.furnitureItem.create({ + data: { + roomId, + type: item.type, + x: item.x, + y: item.y, + width: item.width, + depth: item.depth, + height: item.height, + rotation: item.rotation ?? 0, + elevationFromFloor: item.elevationFromFloor ?? 0, + label: item.label ?? null, + }, + }); + } + }); + + const allItems = await prisma.furnitureItem.findMany({ where: { roomId } }); + return { data: allItems.map(toFurnitureResponse) }; + }, + ); +}; + +export default elementRoutes; diff --git a/apps/server/src/routes/rooms.ts b/apps/server/src/routes/rooms.ts new file mode 100644 index 0000000..555a6b7 --- /dev/null +++ b/apps/server/src/routes/rooms.ts @@ -0,0 +1,222 @@ +import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import { + createRoomSchema, + updateRoomSchema, +} from '@house-plan-maker/shared'; +import type { + Room, + RoomFull, + Point, + ApiResponse, + ApiListResponse, +} from '@house-plan-maker/shared'; +import { + safeJsonParse, + toWallResponse, + toOpeningResponse, + toElectricalResponse, + toFurnitureResponse, +} from '../utils/mappers.js'; + +const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { + const { prisma } = fastify; + + // GET /api/apartments/:id/rooms + fastify.get<{ Params: { id: string } }>( + '/api/apartments/:id/rooms', + async (request, reply): Promise> => { + const apartment = await prisma.apartment.findUnique({ + where: { id: request.params.id }, + }); + + if (!apartment) { + return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 }); + } + + const rooms = await prisma.room.findMany({ + where: { apartmentId: request.params.id }, + orderBy: { order: 'asc' }, + }); + + return { data: rooms.map(toRoomResponse) }; + }, + ); + + // POST /api/apartments/:id/rooms + fastify.post<{ Params: { id: string } }>( + '/api/apartments/:id/rooms', + async (request, reply): Promise> => { + const input = createRoomSchema.parse(request.body); + + const apartment = await prisma.apartment.findUnique({ + where: { id: request.params.id }, + }); + + if (!apartment) { + return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 }); + } + + const room = await prisma.room.create({ + data: { + apartmentId: request.params.id, + name: input.name, + shape: JSON.stringify(input.shape ?? []), + width: input.width ?? null, + height: input.height ?? null, + wallHeight: input.wallHeight ?? 2.7, + plinthHeight: input.plinthHeight ?? 0.06, + plinthThickness: input.plinthThickness ?? 0.01, + order: input.order ?? 0, + // posX/posY added in schema migration; client regeneration required + ...(input.posX !== undefined && { posX: input.posX }), + ...(input.posY !== undefined && { posY: input.posY }), + ...(input.floorType !== undefined && { floorType: input.floorType }), + ...(input.wallColor !== undefined && { wallColor: input.wallColor }), + } as Parameters[0]['data'], + }); + + reply.status(201); + return { data: toRoomResponse(room) }; + }, + ); + + // GET /api/rooms/:id + fastify.get<{ Params: { id: string } }>( + '/api/rooms/:id', + async (request, reply): Promise> => { + const room = await prisma.room.findUnique({ + where: { id: request.params.id }, + }); + + if (!room) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + return { data: toRoomResponse(room) }; + }, + ); + + // GET /api/rooms/:id/full + fastify.get<{ Params: { id: string } }>( + '/api/rooms/:id/full', + async (request, reply): Promise> => { + const room = await prisma.room.findUnique({ + where: { id: request.params.id }, + include: { + walls: true, + openings: true, + electricalItems: true, + furnitureItems: true, + }, + }); + + if (!room) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + return { + data: { + ...toRoomResponse(room), + walls: room.walls.map(toWallResponse), + openings: room.openings.map(toOpeningResponse), + electricalItems: room.electricalItems.map(toElectricalResponse), + furnitureItems: room.furnitureItems.map(toFurnitureResponse), + }, + }; + }, + ); + + // PUT /api/rooms/:id + fastify.put<{ Params: { id: string } }>( + '/api/rooms/:id', + async (request, reply): Promise> => { + const input = updateRoomSchema.parse(request.body); + + const existing = await prisma.room.findUnique({ + where: { id: request.params.id }, + }); + + if (!existing) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + const room = await prisma.room.update({ + where: { id: request.params.id }, + data: { + ...(input.name !== undefined && { name: input.name }), + ...(input.shape !== undefined && { shape: JSON.stringify(input.shape) }), + ...(input.width !== undefined && { width: input.width ?? null }), + ...(input.height !== undefined && { height: input.height ?? null }), + ...(input.wallHeight !== undefined && { wallHeight: input.wallHeight }), + ...(input.plinthHeight !== undefined && { plinthHeight: input.plinthHeight }), + ...(input.plinthThickness !== undefined && { plinthThickness: input.plinthThickness }), + ...(input.order !== undefined && { order: input.order }), + ...(input.posX !== undefined && { posX: input.posX }), + ...(input.posY !== undefined && { posY: input.posY }), + ...(input.floorType !== undefined && { floorType: input.floorType }), + ...(input.wallColor !== undefined && { wallColor: input.wallColor }), + }, + }); + + return { data: toRoomResponse(room) }; + }, + ); + + // DELETE /api/rooms/:id + fastify.delete<{ Params: { id: string } }>( + '/api/rooms/:id', + async (request, reply): Promise => { + const existing = await prisma.room.findUnique({ + where: { id: request.params.id }, + }); + + if (!existing) { + return reply.status(404).send({ error: 'Room not found', statusCode: 404 }); + } + + await prisma.room.delete({ + where: { id: request.params.id }, + }); + + reply.status(204); + }, + ); +}; + +function toRoomResponse(room: { + id: string; + apartmentId: string; + name: string; + shape: string; + width: number | null; + height: number | null; + wallHeight: number; + plinthHeight: number; + plinthThickness: number; + order: number; + posX?: number | null; + posY?: number | null; + createdAt: Date; + updatedAt: Date; +}): Room { + return { + id: room.id, + apartmentId: room.apartmentId, + name: room.name, + shape: safeJsonParse(room.shape, []), + width: room.width, + height: room.height, + wallHeight: room.wallHeight, + plinthHeight: room.plinthHeight, + plinthThickness: room.plinthThickness, + order: room.order, + posX: room.posX ?? 0, + posY: room.posY ?? 0, + floorType: (((room as Record).floorType as string) ?? 'CONCRETE') as Room['floorType'], + wallColor: ((room as Record).wallColor as string) ?? '#f5f0eb', + createdAt: room.createdAt.toISOString(), + updatedAt: room.updatedAt.toISOString(), + }; +} + +export default roomRoutes; diff --git a/apps/server/src/utils/mappers.ts b/apps/server/src/utils/mappers.ts new file mode 100644 index 0000000..3c7102c --- /dev/null +++ b/apps/server/src/utils/mappers.ts @@ -0,0 +1,136 @@ +import { + WALL_DIRECTIONS, + OPENING_TYPES, + DOOR_OPEN_DIRECTIONS, + ELECTRICAL_TYPES, + FURNITURE_TYPES, +} from '@house-plan-maker/shared'; +import type { + Wall, + WallOpening, + ElectricalItem, + FurnitureItem, + WallDirection, + OpeningType, + DoorOpenDirection, + ElectricalType, + FurnitureType, +} from '@house-plan-maker/shared'; + +/** + * Parse a JSON string, returning the fallback on failure. + * The fallback type is independent of T so that `null` can be passed. + */ +export function safeJsonParse(value: string, fallback: F): T | F { + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +function validateEnum( + value: string, + allowed: readonly T[], + fallback: T, +): T { + return (allowed as readonly string[]).includes(value) ? (value as T) : fallback; +} + +export function toWallResponse(wall: { + id: string; + roomId: string; + startX: number; + startY: number; + endX: number; + endY: number; + thickness: number; + direction: string; +}): Wall { + return { + id: wall.id, + roomId: wall.roomId, + startX: wall.startX, + startY: wall.startY, + endX: wall.endX, + endY: wall.endY, + thickness: wall.thickness, + direction: validateEnum(wall.direction, WALL_DIRECTIONS, 'OTHER'), + }; +} + +export function toOpeningResponse(opening: { + id: string; + roomId: string; + wallId: string; + type: string; + positionAlongWall: number; + width: number; + height: number; + elevationFromFloor: number; + openDirection: string; +}): WallOpening { + return { + id: opening.id, + roomId: opening.roomId, + wallId: opening.wallId, + type: validateEnum(opening.type, OPENING_TYPES, 'DOOR'), + positionAlongWall: opening.positionAlongWall, + width: opening.width, + height: opening.height, + elevationFromFloor: opening.elevationFromFloor, + openDirection: validateEnum(opening.openDirection, DOOR_OPEN_DIRECTIONS, 'LEFT'), + }; +} + +export function toElectricalResponse(item: { + id: string; + roomId: string; + type: string; + x: number; + y: number; + wallId: string | null; + elevationFromFloor: number | null; + rotation: number; + metadata: string | null; +}): ElectricalItem { + return { + id: item.id, + roomId: item.roomId, + type: validateEnum(item.type, ELECTRICAL_TYPES, 'OUTLET'), + x: item.x, + y: item.y, + wallId: item.wallId, + elevationFromFloor: item.elevationFromFloor, + rotation: item.rotation, + metadata: item.metadata ? safeJsonParse, null>(item.metadata, null) : null, + }; +} + +export function toFurnitureResponse(item: { + id: string; + roomId: string; + type: string; + x: number; + y: number; + width: number; + depth: number; + height: number; + rotation: number; + elevationFromFloor?: number | null; + label: string | null; +}): FurnitureItem { + return { + id: item.id, + roomId: item.roomId, + type: validateEnum(item.type, FURNITURE_TYPES, 'OTHER'), + x: item.x, + y: item.y, + width: item.width, + depth: item.depth, + height: item.height, + rotation: item.rotation, + elevationFromFloor: item.elevationFromFloor ?? 0, + label: item.label, + }; +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..bf757a5 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../../packages/shared" } + ] +} diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts new file mode 100644 index 0000000..b33bef1 --- /dev/null +++ b/apps/server/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + passWithNoTests: true, + }, +}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..dcfa08a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,49 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + globals: { + ...globals.es2022, + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + }, + }, + { + files: ['apps/client/**/*.{ts,tsx}'], + plugins: { + 'react-hooks': reactHooks, + }, + languageOptions: { + globals: { + ...globals.browser, + }, + }, + rules: { + ...reactHooks.configs.recommended.rules, + }, + }, + { + files: ['apps/server/**/*.ts'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + { + ignores: ['**/dist/**', '**/node_modules/**', '**/*.config.{js,ts}'], + }, +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ff3754f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10690 @@ +{ + "name": "house-plan-maker", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "house-plan-maker", + "workspaces": [ + "apps/*", + "packages/*" + ], + "devDependencies": { + "@eslint/js": "^9.18.0", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^15.14.0", + "prettier": "^3.4.0", + "turbo": "^2.4.0", + "typescript": "^5.7.0", + "typescript-eslint": "^8.20.0" + } + }, + "apps/client": { + "name": "@house-plan-maker/client", + "version": "0.0.0", + "dependencies": { + "@house-plan-maker/shared": "*", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "i18next": "^26.0.3", + "i18next-browser-languagedetector": "^8.2.1", + "jspdf": "^4.2.1", + "konva": "^9.3.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-i18next": "^17.0.2", + "react-konva": "^19.2.3", + "react-router": "^7.14.0", + "three": "^0.183.2" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/three": "^0.183.1", + "@vitejs/plugin-react": "^4.3.0", + "jsdom": "^26.0.0", + "typescript": "^5.7.0", + "vite": "^6.1.0", + "vitest": "^3.0.0" + } + }, + "apps/client/node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "apps/client/node_modules/@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "apps/client/node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "apps/client/node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "apps/client/node_modules/react-konva": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.3.tgz", + "integrity": "sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "dependencies": { + "@types/react-reconciler": "^0.33.0", + "its-fine": "^2.0.0", + "react-reconciler": "0.33.0", + "scheduler": "0.27.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, + "apps/client/node_modules/react-konva/node_modules/@types/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", + "peerDependencies": { + "@types/react": "*" + } + }, + "apps/client/node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "apps/server": { + "name": "@house-plan-maker/server", + "version": "0.0.0", + "dependencies": { + "@house-plan-maker/shared": "*", + "@prisma/client": "^6.3.0", + "fastify": "^5.2.0", + "fastify-plugin": "^5.1.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "prisma": "^6.3.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@house-plan-maker/client": { + "resolved": "apps/client", + "link": true + }, + "node_modules/@house-plan-maker/server": { + "resolved": "apps/server", + "link": true + }, + "node_modules/@house-plan-maker/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true + }, + "node_modules/@turbo/darwin-64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.9.3.tgz", + "integrity": "sha512-P8foouaP+y/p+hhEGBoZpzMbpVvUMwPjDpcy6wN7EYfvvyISD1USuV27qWkczecihwuPJzQ1lDBuL8ERcavTyg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/darwin-arm64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.9.3.tgz", + "integrity": "sha512-SIzEkvtNdzdI50FJDaIQ6kQGqgSSdFPcdn0wqmmONN6iGKjy6hsT+EH99GP65FsfV7DLZTh2NmtTIRl2kdoz5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/linux-64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.9.3.tgz", + "integrity": "sha512-pLRwFmcHHNBvsCySLS6OFabr/07kDT2pxEt/k6eBf/3asiVQZKJ7Rk88AafQx2aYA641qek4RsXvYO3JYpiBug==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@turbo/linux-arm64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.9.3.tgz", + "integrity": "sha512-gy6ApUroC2Nzv+qjGtE/uPNkhHAFU4c8God+zd5Aiv9L9uBgHlxVJpHT3XWl5xwlJZ2KWuMrlHTaS5kmNB+q1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@turbo/windows-64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.9.3.tgz", + "integrity": "sha512-d0YelTX6hAsB7kIEtGB3PzIzSfAg3yDoUlHwuwJc3adBXUsyUIs0YLG+1NNtuhcDOUGnWQeKUoJ2pGWvbpRj7w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@turbo/windows-arm64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.9.3.tgz", + "integrity": "sha512-/08CwpKJl3oRY8nOlh2YgilZVJDHsr60XTNxRhuDeuFXONpUZ5X+Nv65izbG/xBew9qxcJFbDX9/sAmAX+ITcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==" + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "optional": true + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", + "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", + "devOptional": true + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==" + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastify": { + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz", + "integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/i18next": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz", + "integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==" + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/konva": { + "version": "9.3.22", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz", + "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ] + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "optional": true + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, + "node_modules/safe-regex2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", + "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "devOptional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/turbo": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.9.3.tgz", + "integrity": "sha512-J/VUvsGRykPb9R8Kh8dHVBOqioDexLk9BhLCU/ZybRR+HN9UR3cURdazFvNgMDt9zPP8TF6K73Z+tplfmi0PqQ==", + "dev": true, + "bin": { + "turbo": "bin/turbo" + }, + "optionalDependencies": { + "@turbo/darwin-64": "2.9.3", + "@turbo/darwin-arm64": "2.9.3", + "@turbo/linux-64": "2.9.3", + "@turbo/linux-arm64": "2.9.3", + "@turbo/windows-64": "2.9.3", + "@turbo/windows-arm64": "2.9.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==" + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "packages/shared": { + "name": "@house-plan-maker/shared", + "version": "0.0.0", + "dependencies": { + "zod": "^4.3.6" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } + } + }, + "dependencies": { + "@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "requires": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + }, + "dependencies": { + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + } + } + }, + "@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + }, + "@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true + }, + "@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "requires": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true + }, + "@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "requires": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true + }, + "@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true + }, + "@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "requires": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + } + }, + "@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "requires": { + "@babel/types": "^7.29.0" + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" + }, + "@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + } + }, + "@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + } + }, + "@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true + }, + "@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "requires": {} + }, + "@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "requires": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + } + }, + "@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "requires": {} + }, + "@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true + }, + "@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==" + }, + "@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "dev": true, + "optional": true, + "peer": true + }, + "@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "dev": true, + "optional": true, + "peer": true + }, + "@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.4.3" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true + } + } + }, + "@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true + }, + "@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "requires": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + } + }, + "@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "requires": { + "@eslint/core": "^0.17.0" + } + }, + "@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.15" + } + }, + "@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "requires": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true + } + } + }, + "@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true + }, + "@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true + }, + "@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "requires": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + } + }, + "@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "requires": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + } + } + }, + "@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==" + }, + "@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "requires": { + "fast-json-stringify": "^6.0.0" + } + }, + "@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==" + }, + "@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "requires": { + "dequal": "^2.0.3" + } + }, + "@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "requires": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "@house-plan-maker/client": { + "version": "file:apps/client", + "requires": { + "@house-plan-maker/shared": "*", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/three": "^0.183.1", + "@vitejs/plugin-react": "^4.3.0", + "i18next": "^26.0.3", + "i18next-browser-languagedetector": "^8.2.1", + "jsdom": "^26.0.0", + "jspdf": "^4.2.1", + "konva": "^9.3.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-i18next": "^17.0.2", + "react-konva": "^19.2.3", + "react-router": "^7.14.0", + "three": "^0.183.2", + "typescript": "^5.7.0", + "vite": "^6.1.0", + "vitest": "^3.0.0" + }, + "dependencies": { + "@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "requires": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + } + }, + "@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "requires": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + } + }, + "its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "requires": { + "@types/react-reconciler": "^0.28.9" + } + }, + "react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" + }, + "react-konva": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.3.tgz", + "integrity": "sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==", + "requires": { + "@types/react-reconciler": "^0.33.0", + "its-fine": "^2.0.0", + "react-reconciler": "0.33.0", + "scheduler": "0.27.0" + }, + "dependencies": { + "@types/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", + "requires": {} + } + } + }, + "react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "requires": { + "scheduler": "^0.27.0" + } + } + } + }, + "@house-plan-maker/server": { + "version": "file:apps/server", + "requires": { + "@house-plan-maker/shared": "*", + "@prisma/client": "^6.3.0", + "fastify": "^5.2.0", + "fastify-plugin": "^5.1.0", + "prisma": "^6.3.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "zod": "^4.3.6" + } + }, + "@house-plan-maker/shared": { + "version": "file:packages/shared", + "requires": { + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "zod": "^4.3.6" + } + }, + "@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true + }, + "@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "requires": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + } + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true + }, + "@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==" + }, + "@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "requires": { + "promise-worker-transferable": "^1.0.4" + } + }, + "@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" + }, + "@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "requires": {} + }, + "@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "requires": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true + }, + "@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "requires": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true + }, + "@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "requires": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "requires": { + "@prisma/debug": "6.19.3" + } + }, + "@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "dev": true, + "optional": true + }, + "@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true + }, + "@turbo/darwin-64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.9.3.tgz", + "integrity": "sha512-P8foouaP+y/p+hhEGBoZpzMbpVvUMwPjDpcy6wN7EYfvvyISD1USuV27qWkczecihwuPJzQ1lDBuL8ERcavTyg==", + "dev": true, + "optional": true + }, + "@turbo/darwin-arm64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.9.3.tgz", + "integrity": "sha512-SIzEkvtNdzdI50FJDaIQ6kQGqgSSdFPcdn0wqmmONN6iGKjy6hsT+EH99GP65FsfV7DLZTh2NmtTIRl2kdoz5Q==", + "dev": true, + "optional": true + }, + "@turbo/linux-64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.9.3.tgz", + "integrity": "sha512-pLRwFmcHHNBvsCySLS6OFabr/07kDT2pxEt/k6eBf/3asiVQZKJ7Rk88AafQx2aYA641qek4RsXvYO3JYpiBug==", + "dev": true, + "optional": true + }, + "@turbo/linux-arm64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.9.3.tgz", + "integrity": "sha512-gy6ApUroC2Nzv+qjGtE/uPNkhHAFU4c8God+zd5Aiv9L9uBgHlxVJpHT3XWl5xwlJZ2KWuMrlHTaS5kmNB+q1Q==", + "dev": true, + "optional": true + }, + "@turbo/windows-64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.9.3.tgz", + "integrity": "sha512-d0YelTX6hAsB7kIEtGB3PzIzSfAg3yDoUlHwuwJc3adBXUsyUIs0YLG+1NNtuhcDOUGnWQeKUoJ2pGWvbpRj7w==", + "dev": true, + "optional": true + }, + "@turbo/windows-arm64": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.9.3.tgz", + "integrity": "sha512-/08CwpKJl3oRY8nOlh2YgilZVJDHsr60XTNxRhuDeuFXONpUZ5X+Nv65izbG/xBew9qxcJFbDX9/sAmAX+ITcQ==", + "dev": true, + "optional": true + }, + "@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==" + }, + "@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "requires": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "requires": { + "@babel/types": "^7.28.2" + } + }, + "@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "requires": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==" + }, + "@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==" + }, + "@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==" + }, + "@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "optional": true + }, + "@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "requires": { + "csstype": "^3.2.2" + } + }, + "@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "requires": {} + }, + "@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "requires": {} + }, + "@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==" + }, + "@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "requires": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, + "@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==" + }, + "@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "dependencies": { + "ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true + } + } + }, + "@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + } + }, + "@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "requires": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + } + }, + "@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + } + }, + "@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "requires": {} + }, + "@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + } + }, + "@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + } + } + }, + "@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + } + } + }, + "@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==" + }, + "@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "requires": { + "@use-gesture/core": "10.3.1" + } + }, + "@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "requires": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + } + }, + "@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "requires": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + } + }, + "@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "requires": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + } + }, + "@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "requires": { + "tinyrainbow": "^2.0.0" + } + }, + "@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "requires": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + } + }, + "@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "requires": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + } + }, + "@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "requires": { + "tinyspy": "^4.0.3" + } + }, + "@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "requires": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + } + }, + "@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==" + }, + "abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true + }, + "ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "requires": { + "ajv": "^8.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + } + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true + }, + "atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" + }, + "avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "requires": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "optional": true + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "dev": true + }, + "bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "requires": { + "require-from-string": "^2.0.2" + } + }, + "brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "requires": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + } + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "requires": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + } + }, + "cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "requires": {} + }, + "caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "dev": true + }, + "canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "optional": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + } + }, + "chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "requires": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true + }, + "chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "requires": { + "readdirp": "^4.0.1" + } + }, + "citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "requires": { + "consola": "^3.2.3" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true + }, + "consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==" + }, + "core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "optional": true + }, + "cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "requires": { + "cross-spawn": "^7.0.1" + } + }, + "cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "optional": true, + "requires": { + "utrie": "^1.0.2" + } + }, + "cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "requires": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + } + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "requires": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + } + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, + "deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true + }, + "defu": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", + "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", + "devOptional": true + }, + "dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" + }, + "destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true + }, + "detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "requires": { + "webgl-constants": "^1.1.1" + } + }, + "dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "optional": true, + "requires": { + "@types/trusted-types": "^2.0.7" + } + }, + "dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true + }, + "draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==" + }, + "effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "requires": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true + }, + "empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true + }, + "entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true + }, + "es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + } + }, + "eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "requires": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + } + }, + "eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true + }, + "espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "requires": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + } + }, + "esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true + }, + "exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true + }, + "fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "requires": { + "pure-rand": "^6.1.0" + } + }, + "fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==" + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "requires": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + }, + "dependencies": { + "ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + } + } + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "requires": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "requires": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==" + }, + "fastify": { + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz", + "integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==", + "requires": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==" + }, + "fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "requires": { + "reusify": "^1.0.4" + } + }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, + "file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "requires": { + "flat-cache": "^4.0.0" + } + }, + "find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "requires": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + } + }, + "flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "requires": { + "resolve-pkg-maps": "^1.0.0" + } + }, + "giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "requires": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true + }, + "glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true + }, + "hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "requires": { + "hermes-estree": "0.25.1" + } + }, + "hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==" + }, + "html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "requires": { + "whatwg-encoding": "^3.1.1" + } + }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "requires": { + "void-elements": "3.1.0" + } + }, + "html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "optional": true, + "requires": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + } + }, + "http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + } + }, + "https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "requires": { + "agent-base": "^7.1.2", + "debug": "4" + } + }, + "i18next": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz", + "integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==", + "requires": { + "@babel/runtime": "^7.29.2" + } + }, + "i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "requires": { + "@babel/runtime": "^7.23.2" + } + }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true + }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==" + }, + "ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==" + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "requires": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + } + }, + "jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "requires": { + "dequal": "^2.0.3" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "requires": { + "@babel/runtime": "^7.28.6", + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "fast-png": "^6.2.0", + "fflate": "^0.8.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, + "konva": { + "version": "9.3.22", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz", + "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==" + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "requires": { + "immediate": "~3.0.5" + } + }, + "light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "requires": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + }, + "dependencies": { + "process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==" + } + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "requires": {} + }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "requires": {} + }, + "meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==" + }, + "minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true + }, + "node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true + }, + "nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true + }, + "nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "requires": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "dependencies": { + "citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true + } + } + }, + "ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true + }, + "on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==" + }, + "optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "requires": { + "entities": "^6.0.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true + }, + "pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true + }, + "perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "optional": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + }, + "pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "requires": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + } + }, + "pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "requires": { + "split2": "^4.0.0" + } + }, + "pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" + }, + "pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "requires": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "requires": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==" + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true + }, + "prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "requires": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + } + }, + "process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==" + }, + "promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "requires": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true + }, + "quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "optional": true, + "requires": { + "performance-now": "^2.1.0" + } + }, + "rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "requires": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, + "requires": { + "loose-envify": "^1.1.0" + } + }, + "react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "requires": { + "scheduler": "^0.27.0" + } + }, + "react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "requires": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + } + }, + "react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true + }, + "react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "requires": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + } + }, + "react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "requires": {} + }, + "readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true + }, + "real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==" + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "optional": true + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true + }, + "ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==" + }, + "reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" + }, + "rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "optional": true + }, + "rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@types/estree": "1.0.8", + "fsevents": "~2.3.2" + } + }, + "rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, + "safe-regex2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", + "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "requires": { + "ret": "~0.5.0" + } + }, + "safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "requires": { + "xmlchars": "^2.2.0" + } + }, + "scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==" + }, + "semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==" + }, + "set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "requires": { + "atomic-sleep": "^1.0.0" + } + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" + }, + "stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "optional": true + }, + "stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "requires": { + "@types/three": "*", + "three": "^0.170.0" + }, + "dependencies": { + "three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==" + } + } + }, + "stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==" + }, + "std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "requires": { + "js-tokens": "^9.0.1" + }, + "dependencies": { + "js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + } + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "requires": {} + }, + "svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "optional": true + }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "optional": true, + "requires": { + "utrie": "^1.0.2" + } + }, + "thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "requires": { + "real-require": "^0.2.0" + } + }, + "three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==" + }, + "three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "requires": {} + }, + "three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "requires": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "dependencies": { + "fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==" + } + } + }, + "tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "devOptional": true + }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + } + }, + "tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true + }, + "tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true + }, + "tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true + }, + "tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "requires": { + "tldts-core": "^6.1.86" + } + }, + "tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true + }, + "toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==" + }, + "tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "requires": { + "tldts": "^6.1.32" + } + }, + "tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "requires": { + "punycode": "^2.3.1" + } + }, + "troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "requires": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + } + }, + "troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "requires": {} + }, + "troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==" + }, + "ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "requires": {} + }, + "tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "requires": { + "esbuild": "~0.27.0", + "fsevents": "~2.3.3", + "get-tsconfig": "^4.7.5" + } + }, + "tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "requires": { + "zustand": "^4.3.2" + }, + "dependencies": { + "zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "requires": { + "use-sync-external-store": "^1.2.2" + } + } + } + }, + "turbo": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.9.3.tgz", + "integrity": "sha512-J/VUvsGRykPb9R8Kh8dHVBOqioDexLk9BhLCU/ZybRR+HN9UR3cURdazFvNgMDt9zPP8TF6K73Z+tplfmi0PqQ==", + "dev": true, + "requires": { + "@turbo/darwin-64": "2.9.3", + "@turbo/darwin-arm64": "2.9.3", + "@turbo/linux-64": "2.9.3", + "@turbo/linux-arm64": "2.9.3", + "@turbo/windows-64": "2.9.3", + "@turbo/windows-arm64": "2.9.3" + } + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true + }, + "typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "requires": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + } + }, + "update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "requires": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "requires": {} + }, + "utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==" + }, + "utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "optional": true, + "requires": { + "base64-arraybuffer": "^1.0.2" + } + }, + "vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "requires": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "fsevents": "~2.3.3", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "dependencies": { + "@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "dev": true, + "optional": true + }, + "esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + } + } + }, + "vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "requires": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + } + }, + "vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "requires": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "dependencies": { + "tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + } + } + }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + }, + "w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "requires": { + "xml-name-validator": "^5.0.0" + } + }, + "webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==" + }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true + }, + "whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "requires": { + "iconv-lite": "0.6.3" + } + }, + "whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true + }, + "whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "requires": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "requires": { + "isexe": "^2.0.0" + } + }, + "why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "requires": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + } + }, + "word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true + }, + "ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "requires": {} + }, + "xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + }, + "zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" + }, + "zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "requires": {} + }, + "zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "requires": {} + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..30b0400 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "house-plan-maker", + "private": true, + "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev": "turbo run dev", + "build": "turbo run build", + "test": "turbo run test", + "lint": "turbo run lint", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"" + }, + "packageManager": "npm@10.8.0", + "devDependencies": { + "@eslint/js": "^9.18.0", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^15.14.0", + "prettier": "^3.4.0", + "turbo": "^2.4.0", + "typescript": "^5.7.0", + "typescript-eslint": "^8.20.0" + } +} diff --git a/packages/shared/package-lock.json b/packages/shared/package-lock.json new file mode 100644 index 0000000..d674726 --- /dev/null +++ b/packages/shared/package-lock.json @@ -0,0 +1,2343 @@ +{ + "name": "@house-plan-maker/shared", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@house-plan-maker/shared", + "version": "0.0.0", + "dependencies": { + "zod": "^4.3.6" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + }, + "dependencies": { + "@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "dev": true, + "optional": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "dev": true, + "optional": true + }, + "@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "requires": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "requires": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + } + }, + "@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "requires": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + } + }, + "@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "requires": { + "tinyrainbow": "^2.0.0" + } + }, + "@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "requires": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + } + }, + "@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "requires": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + } + }, + "@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "requires": { + "tinyspy": "^4.0.3" + } + }, + "@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "requires": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + } + }, + "assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true + }, + "cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true + }, + "chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "requires": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + } + }, + "check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true + }, + "es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, + "expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true + }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true + }, + "pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + }, + "postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "requires": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@types/estree": "1.0.8", + "fsevents": "~2.3.2" + } + }, + "siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "requires": { + "js-tokens": "^9.0.1" + } + }, + "tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + } + }, + "tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true + }, + "tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true + }, + "tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + }, + "vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "requires": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "fsevents": "~2.3.3", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + } + }, + "vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "requires": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + } + }, + "vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "requires": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + } + }, + "why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "requires": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + } + }, + "zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" + } + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..0c3bdb9 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,27 @@ +{ + "name": "@house-plan-maker/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch", + "test": "vitest run", + "lint": "eslint src/" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "dependencies": { + "zod": "^4.3.6" + } +} diff --git a/packages/shared/src/__tests__/schemas.test.ts b/packages/shared/src/__tests__/schemas.test.ts new file mode 100644 index 0000000..5ffc36f --- /dev/null +++ b/packages/shared/src/__tests__/schemas.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { createApartmentSchema, updateApartmentSchema } from '../schemas/apartment.schema.js'; +import { createRoomSchema, updateRoomSchema } from '../schemas/room.schema.js'; + +describe('createApartmentSchema', () => { + it('validates a valid apartment', () => { + const result = createApartmentSchema.parse({ + name: 'Test Apartment', + address: '123 Main St', + totalArea: 75.5, + }); + expect(result.name).toBe('Test Apartment'); + expect(result.address).toBe('123 Main St'); + expect(result.totalArea).toBe(75.5); + }); + + it('requires name', () => { + expect(() => createApartmentSchema.parse({})).toThrow(); + }); + + it('rejects empty name', () => { + expect(() => createApartmentSchema.parse({ name: '' })).toThrow(); + }); + + it('allows optional address and totalArea', () => { + const result = createApartmentSchema.parse({ name: 'Test' }); + expect(result.name).toBe('Test'); + }); + + it('rejects negative totalArea', () => { + expect(() => createApartmentSchema.parse({ name: 'Test', totalArea: -10 })).toThrow(); + }); +}); + +describe('updateApartmentSchema', () => { + it('allows partial update', () => { + const result = updateApartmentSchema.parse({ name: 'Updated' }); + expect(result.name).toBe('Updated'); + }); + + it('allows empty object', () => { + const result = updateApartmentSchema.parse({}); + expect(result).toBeDefined(); + }); +}); + +describe('createRoomSchema', () => { + it('validates a valid room', () => { + const result = createRoomSchema.parse({ + name: 'Bedroom', + shape: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 3 }, { x: 0, y: 3 }], + width: 4, + height: 3, + wallHeight: 2.7, + }); + expect(result.name).toBe('Bedroom'); + expect(result.shape).toHaveLength(4); + }); + + it('requires name', () => { + expect(() => createRoomSchema.parse({})).toThrow(); + }); + + it('rejects empty name', () => { + expect(() => createRoomSchema.parse({ name: '' })).toThrow(); + }); + + it('allows optional fields', () => { + const result = createRoomSchema.parse({ name: 'Room' }); + expect(result.name).toBe('Room'); + }); + + it('rejects negative width', () => { + expect(() => createRoomSchema.parse({ name: 'Room', width: -1 })).toThrow(); + }); + + it('rejects negative wallHeight', () => { + expect(() => createRoomSchema.parse({ name: 'Room', wallHeight: -1 })).toThrow(); + }); + + it('allows zero plinthHeight', () => { + const result = createRoomSchema.parse({ name: 'Room', plinthHeight: 0 }); + expect(result.plinthHeight).toBe(0); + }); + + it('validates order as non-negative integer', () => { + const result = createRoomSchema.parse({ name: 'Room', order: 0 }); + expect(result.order).toBe(0); + expect(() => createRoomSchema.parse({ name: 'Room', order: -1 })).toThrow(); + expect(() => createRoomSchema.parse({ name: 'Room', order: 1.5 })).toThrow(); + }); +}); + +describe('updateRoomSchema', () => { + it('allows partial update', () => { + const result = updateRoomSchema.parse({ name: 'Updated Room' }); + expect(result.name).toBe('Updated Room'); + }); + + it('allows empty object', () => { + const result = updateRoomSchema.parse({}); + expect(result).toBeDefined(); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..fc513de --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,103 @@ +/** + * Shared types and utilities for the House Plan Maker application. + */ + +export interface HealthCheckResponse { + status: 'ok' | 'error'; + timestamp: string; +} + +// Types +export type { + Apartment, + ApartmentWithRooms, + ApartmentRoom, + CreateApartmentDto, + UpdateApartmentDto, +} from './types/apartment.js'; + +export type { + Point, + FloorType, + Room, + RoomFull, + CreateRoomDto, + UpdateRoomDto, +} from './types/room.js'; + +export { FLOOR_TYPES } from './types/room.js'; + +export type { + WallDirection, + Wall, + CreateWallDto, + OpeningType, + DoorOpenDirection, + WallOpening, + CreateWallOpeningDto, + UpdateWallOpeningDto, + ElectricalType, + ElectricalItem, + CreateElectricalItemDto, + UpdateElectricalItemDto, + FurnitureType, + FurnitureItem, + CreateFurnitureItemDto, + UpdateFurnitureItemDto, + Annotation, + BatchSyncOpeningsDto, + BatchSyncElectricalDto, + BatchSyncFurnitureDto, +} from './types/elements.js'; + +export { + WALL_DIRECTIONS, + OPENING_TYPES, + DOOR_OPEN_DIRECTIONS, + ELECTRICAL_TYPES, + FURNITURE_TYPES, +} from './types/elements.js'; + +export type { + ApiResponse, + ApiListResponse, + ApiErrorResponse, +} from './types/api.js'; + +// Zod schemas +export { + createApartmentSchema, + updateApartmentSchema, + type CreateApartmentInput, + type UpdateApartmentInput, +} from './schemas/apartment.schema.js'; + +export { + createRoomSchema, + updateRoomSchema, + type CreateRoomInput, + type UpdateRoomInput, +} from './schemas/room.schema.js'; + +export { + bulkUpdateWallsSchema, + createWallOpeningSchema, + updateWallOpeningSchema, + createElectricalItemSchema, + updateElectricalItemSchema, + createFurnitureItemSchema, + updateFurnitureItemSchema, + batchSyncOpeningsSchema, + batchSyncElectricalSchema, + batchSyncFurnitureSchema, + type BulkUpdateWallsInput, + type CreateWallOpeningInput, + type UpdateWallOpeningInput, + type CreateElectricalItemInput, + type UpdateElectricalItemInput, + type CreateFurnitureItemInput, + type UpdateFurnitureItemInput, + type BatchSyncOpeningsInput, + type BatchSyncElectricalInput, + type BatchSyncFurnitureInput, +} from './schemas/elements.schema.js'; diff --git a/packages/shared/src/schemas/apartment.schema.ts b/packages/shared/src/schemas/apartment.schema.ts new file mode 100644 index 0000000..0685fbb --- /dev/null +++ b/packages/shared/src/schemas/apartment.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const createApartmentSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255), + address: z.string().max(500).nullish(), + totalArea: z.number().positive('Total area must be positive').nullish(), +}); + +export const updateApartmentSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255).optional(), + address: z.string().max(500).nullish(), + totalArea: z.number().positive('Total area must be positive').nullish(), +}); + +export type CreateApartmentInput = z.infer; +export type UpdateApartmentInput = z.infer; diff --git a/packages/shared/src/schemas/elements.schema.ts b/packages/shared/src/schemas/elements.schema.ts new file mode 100644 index 0000000..fc8aabf --- /dev/null +++ b/packages/shared/src/schemas/elements.schema.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; +import { WALL_DIRECTIONS, OPENING_TYPES, DOOR_OPEN_DIRECTIONS, ELECTRICAL_TYPES, FURNITURE_TYPES } from '../types/elements.js'; + +// ── Wall schemas ── + +const wallDirectionEnum = z.enum(WALL_DIRECTIONS); + +const createWallItemSchema = z.object({ + startX: z.number(), + startY: z.number(), + endX: z.number(), + endY: z.number(), + thickness: z.number().positive('Thickness must be positive').optional(), + direction: wallDirectionEnum.optional(), +}); + +/** Bulk update: replace all walls in a room. */ +export const bulkUpdateWallsSchema = z.object({ + walls: z.array(createWallItemSchema), +}); + +export type BulkUpdateWallsInput = z.infer; + +// ── WallOpening schemas ── + +const openingTypeEnum = z.enum(OPENING_TYPES); +const doorOpenDirectionEnum = z.enum(DOOR_OPEN_DIRECTIONS); + +export const createWallOpeningSchema = z.object({ + wallId: z.string().min(1, 'Wall ID is required'), + type: openingTypeEnum, + positionAlongWall: z.number().min(0, 'Position must be non-negative'), + width: z.number().positive('Width must be positive'), + height: z.number().positive('Height must be positive'), + elevationFromFloor: z.number().min(0).optional(), + openDirection: doorOpenDirectionEnum.optional(), +}); + +export const updateWallOpeningSchema = z.object({ + type: openingTypeEnum.optional(), + positionAlongWall: z.number().min(0, 'Position must be non-negative').optional(), + width: z.number().positive('Width must be positive').optional(), + height: z.number().positive('Height must be positive').optional(), + elevationFromFloor: z.number().min(0).optional(), + openDirection: doorOpenDirectionEnum.optional(), +}); + +export type CreateWallOpeningInput = z.infer; +export type UpdateWallOpeningInput = z.infer; + +// ── ElectricalItem schemas ── + +const electricalTypeEnum = z.enum(ELECTRICAL_TYPES); + +export const createElectricalItemSchema = z.object({ + type: electricalTypeEnum, + x: z.number(), + y: z.number(), + wallId: z.string().nullish(), + elevationFromFloor: z.number().min(0).nullish(), + rotation: z.number().min(0).max(360).optional(), + metadata: z.record(z.string(), z.unknown()).nullish(), +}); + +export const updateElectricalItemSchema = z.object({ + type: electricalTypeEnum.optional(), + x: z.number().optional(), + y: z.number().optional(), + wallId: z.string().nullish(), + elevationFromFloor: z.number().min(0).nullish(), + rotation: z.number().min(0).max(360).optional(), + metadata: z.record(z.string(), z.unknown()).nullish(), +}); + +export type CreateElectricalItemInput = z.infer; +export type UpdateElectricalItemInput = z.infer; + +// ── FurnitureItem schemas ── + +const furnitureTypeEnum = z.enum(FURNITURE_TYPES); + +export const createFurnitureItemSchema = z.object({ + type: furnitureTypeEnum, + x: z.number(), + y: z.number(), + width: z.number().positive('Width must be positive'), + depth: z.number().positive('Depth must be positive'), + height: z.number().positive('Height must be positive'), + rotation: z.number().min(0).max(360).optional(), + elevationFromFloor: z.number().min(0).optional(), + label: z.string().max(255).nullish(), +}); + +export const updateFurnitureItemSchema = z.object({ + type: furnitureTypeEnum.optional(), + x: z.number().optional(), + y: z.number().optional(), + width: z.number().positive('Width must be positive').optional(), + depth: z.number().positive('Depth must be positive').optional(), + height: z.number().positive('Height must be positive').optional(), + rotation: z.number().min(0).max(360).optional(), + elevationFromFloor: z.number().min(0).optional(), + label: z.string().max(255).nullish(), +}); + +export type CreateFurnitureItemInput = z.infer; +export type UpdateFurnitureItemInput = z.infer; + +// ── Batch sync schemas ── + +export const batchSyncOpeningsSchema = z.object({ + create: z.array(createWallOpeningSchema), + update: z.array( + z.object({ + id: z.string().min(1), + data: updateWallOpeningSchema, + }), + ), + delete: z.array(z.string().min(1)), +}); + +export type BatchSyncOpeningsInput = z.infer; + +export const batchSyncElectricalSchema = z.object({ + create: z.array(createElectricalItemSchema), + update: z.array( + z.object({ + id: z.string().min(1), + data: updateElectricalItemSchema, + }), + ), + delete: z.array(z.string().min(1)), +}); + +export type BatchSyncElectricalInput = z.infer; + +export const batchSyncFurnitureSchema = z.object({ + create: z.array(createFurnitureItemSchema), + update: z.array( + z.object({ + id: z.string().min(1), + data: updateFurnitureItemSchema, + }), + ), + delete: z.array(z.string().min(1)), +}); + +export type BatchSyncFurnitureInput = z.infer; diff --git a/packages/shared/src/schemas/room.schema.ts b/packages/shared/src/schemas/room.schema.ts new file mode 100644 index 0000000..410a22a --- /dev/null +++ b/packages/shared/src/schemas/room.schema.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { FLOOR_TYPES } from '../types/room.js'; + +const floorTypeEnum = z.enum(FLOOR_TYPES); + +const pointSchema = z.object({ + x: z.number(), + y: z.number(), +}); + +export const createRoomSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255), + shape: z.array(pointSchema).optional(), + width: z.number().positive('Width must be positive').nullish(), + height: z.number().positive('Height must be positive').nullish(), + wallHeight: z.number().positive('Wall height must be positive').optional(), + plinthHeight: z.number().min(0, 'Plinth height must be non-negative').optional(), + plinthThickness: z.number().min(0, 'Plinth thickness must be non-negative').optional(), + order: z.number().int().min(0).optional(), + posX: z.number().optional(), + posY: z.number().optional(), + floorType: floorTypeEnum.optional(), + wallColor: z.string().max(20).optional(), +}); + +export const updateRoomSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255).optional(), + shape: z.array(pointSchema).optional(), + width: z.number().positive('Width must be positive').nullish(), + height: z.number().positive('Height must be positive').nullish(), + wallHeight: z.number().positive('Wall height must be positive').optional(), + plinthHeight: z.number().min(0, 'Plinth height must be non-negative').optional(), + plinthThickness: z.number().min(0, 'Plinth thickness must be non-negative').optional(), + order: z.number().int().min(0).optional(), + posX: z.number().optional(), + posY: z.number().optional(), + floorType: floorTypeEnum.optional(), + wallColor: z.string().max(20).optional(), +}); + +export type CreateRoomInput = z.infer; +export type UpdateRoomInput = z.infer; diff --git a/packages/shared/src/types/apartment.ts b/packages/shared/src/types/apartment.ts new file mode 100644 index 0000000..0c1c6ee --- /dev/null +++ b/packages/shared/src/types/apartment.ts @@ -0,0 +1,32 @@ +export interface Apartment { + readonly id: string; + readonly name: string; + readonly address: string | null; + readonly totalArea: number | null; + readonly roomCount?: number; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface ApartmentWithRooms extends Apartment { + readonly rooms: readonly ApartmentRoom[]; +} + +/** Lightweight room info when nested inside an apartment response. */ +export interface ApartmentRoom { + readonly id: string; + readonly name: string; + readonly order: number; +} + +export interface CreateApartmentDto { + readonly name: string; + readonly address?: string | null; + readonly totalArea?: number | null; +} + +export interface UpdateApartmentDto { + readonly name?: string; + readonly address?: string | null; + readonly totalArea?: number | null; +} diff --git a/packages/shared/src/types/api.ts b/packages/shared/src/types/api.ts new file mode 100644 index 0000000..9f39ee6 --- /dev/null +++ b/packages/shared/src/types/api.ts @@ -0,0 +1,15 @@ +/** Standard success response wrapper. */ +export interface ApiResponse { + readonly data: T; +} + +/** Standard list response wrapper. */ +export interface ApiListResponse { + readonly data: readonly T[]; +} + +/** Standard error response. */ +export interface ApiErrorResponse { + readonly error: string; + readonly statusCode: number; +} diff --git a/packages/shared/src/types/elements.ts b/packages/shared/src/types/elements.ts new file mode 100644 index 0000000..568bc10 --- /dev/null +++ b/packages/shared/src/types/elements.ts @@ -0,0 +1,198 @@ +// ── Wall ── + +export const WALL_DIRECTIONS = ['NORTH', 'SOUTH', 'EAST', 'WEST', 'OTHER'] as const; +export type WallDirection = (typeof WALL_DIRECTIONS)[number]; + +export interface Wall { + readonly id: string; + readonly roomId: string; + readonly startX: number; + readonly startY: number; + readonly endX: number; + readonly endY: number; + readonly thickness: number; + readonly direction: WallDirection; +} + +export interface CreateWallDto { + readonly startX: number; + readonly startY: number; + readonly endX: number; + readonly endY: number; + readonly thickness?: number; + readonly direction?: WallDirection; +} + +// ── WallOpening ── + +export const OPENING_TYPES = ['DOOR', 'WINDOW'] as const; +export type OpeningType = (typeof OPENING_TYPES)[number]; + +export const DOOR_OPEN_DIRECTIONS = ['LEFT', 'RIGHT', 'INWARD', 'OUTWARD'] as const; +export type DoorOpenDirection = (typeof DOOR_OPEN_DIRECTIONS)[number]; + +export interface WallOpening { + readonly id: string; + readonly roomId: string; + readonly wallId: string; + readonly type: OpeningType; + readonly positionAlongWall: number; + readonly width: number; + readonly height: number; + readonly elevationFromFloor: number; + readonly openDirection: DoorOpenDirection; +} + +export interface CreateWallOpeningDto { + readonly wallId: string; + readonly type: OpeningType; + readonly positionAlongWall: number; + readonly width: number; + readonly height: number; + readonly elevationFromFloor?: number; + readonly openDirection?: DoorOpenDirection; +} + +export interface UpdateWallOpeningDto { + readonly type?: OpeningType; + readonly positionAlongWall?: number; + readonly width?: number; + readonly height?: number; + readonly elevationFromFloor?: number; + readonly openDirection?: DoorOpenDirection; +} + +// ── ElectricalItem ── + +export const ELECTRICAL_TYPES = [ + 'OUTLET', + 'SWITCH', + 'JUNCTION_BOX', + 'LIGHT_CEILING', + 'LIGHT_WALL', + 'CABLE_ROUTE', +] as const; +export type ElectricalType = (typeof ELECTRICAL_TYPES)[number]; + +export interface ElectricalItem { + readonly id: string; + readonly roomId: string; + readonly type: ElectricalType; + readonly x: number; + readonly y: number; + readonly wallId: string | null; + readonly elevationFromFloor: number | null; + readonly rotation: number; + readonly metadata: Record | null; +} + +export interface CreateElectricalItemDto { + readonly type: ElectricalType; + readonly x: number; + readonly y: number; + readonly wallId?: string | null; + readonly elevationFromFloor?: number | null; + readonly rotation?: number; + readonly metadata?: Record | null; +} + +export interface UpdateElectricalItemDto { + readonly type?: ElectricalType; + readonly x?: number; + readonly y?: number; + readonly wallId?: string | null; + readonly elevationFromFloor?: number | null; + readonly rotation?: number; + readonly metadata?: Record | null; +} + +// ── FurnitureItem ── + +export const FURNITURE_TYPES = [ + 'BED', + 'DESK', + 'WARDROBE', + 'SOFA', + 'TABLE', + 'CHAIR', + 'SHELF', + 'NIGHTSTAND', + 'DRESSER', + 'BOOKCASE', + 'TV', + 'AC_UNIT', + 'OTHER', +] as const; +export type FurnitureType = (typeof FURNITURE_TYPES)[number]; + +export interface FurnitureItem { + readonly id: string; + readonly roomId: string; + readonly type: FurnitureType; + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly height: number; + readonly rotation: number; + readonly elevationFromFloor: number; + readonly label: string | null; +} + +export interface CreateFurnitureItemDto { + readonly type: FurnitureType; + readonly x: number; + readonly y: number; + readonly width: number; + readonly depth: number; + readonly height: number; + readonly rotation?: number; + readonly elevationFromFloor?: number; + readonly label?: string | null; +} + +export interface UpdateFurnitureItemDto { + readonly type?: FurnitureType; + readonly x?: number; + readonly y?: number; + readonly width?: number; + readonly depth?: number; + readonly height?: number; + readonly rotation?: number; + readonly elevationFromFloor?: number; + readonly label?: string | null; +} + +// ── Annotation ── + +export interface Annotation { + readonly id: string; + readonly roomId: string; + readonly x: number; + readonly y: number; + readonly text: string; + readonly fontSize?: number; + readonly color?: string; + /** If set, this annotation follows the item with this ID. x,y become offsets from the item. */ + readonly attachedToId?: string; +} + +// ── Batch sync DTOs ── + +export interface BatchSyncOpeningsDto { + readonly create: readonly CreateWallOpeningDto[]; + readonly update: readonly { readonly id: string; readonly data: UpdateWallOpeningDto }[]; + readonly delete: readonly string[]; +} + +export interface BatchSyncElectricalDto { + readonly create: readonly CreateElectricalItemDto[]; + readonly update: readonly { readonly id: string; readonly data: UpdateElectricalItemDto }[]; + readonly delete: readonly string[]; +} + +export interface BatchSyncFurnitureDto { + readonly create: readonly CreateFurnitureItemDto[]; + readonly update: readonly { readonly id: string; readonly data: UpdateFurnitureItemDto }[]; + readonly delete: readonly string[]; +} diff --git a/packages/shared/src/types/room.ts b/packages/shared/src/types/room.ts new file mode 100644 index 0000000..a3125be --- /dev/null +++ b/packages/shared/src/types/room.ts @@ -0,0 +1,74 @@ +import type { Wall, WallOpening, ElectricalItem, FurnitureItem } from './elements.js'; + +export interface Point { + readonly x: number; + readonly y: number; +} + +export const FLOOR_TYPES = [ + 'CONCRETE', + 'WOOD_LIGHT', + 'WOOD_MEDIUM', + 'WOOD_DARK', + 'WOOD_HERRINGBONE', + 'TILE_WHITE', + 'TILE_GRAY', + 'LAMINATE', +] as const; +export type FloorType = (typeof FLOOR_TYPES)[number]; + +export interface Room { + readonly id: string; + readonly apartmentId: string; + readonly name: string; + readonly shape: readonly Point[]; + readonly width: number | null; + readonly height: number | null; + readonly wallHeight: number; + readonly plinthHeight: number; + readonly plinthThickness: number; + readonly order: number; + readonly posX: number; + readonly posY: number; + readonly floorType: FloorType; + readonly wallColor: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface RoomFull extends Room { + readonly walls: readonly Wall[]; + readonly openings: readonly WallOpening[]; + readonly electricalItems: readonly ElectricalItem[]; + readonly furnitureItems: readonly FurnitureItem[]; +} + +export interface CreateRoomDto { + readonly name: string; + readonly shape?: readonly Point[]; + readonly width?: number | null; + readonly height?: number | null; + readonly wallHeight?: number; + readonly plinthHeight?: number; + readonly plinthThickness?: number; + readonly order?: number; + readonly posX?: number; + readonly posY?: number; + readonly floorType?: FloorType; + readonly wallColor?: string; +} + +export interface UpdateRoomDto { + readonly name?: string; + readonly shape?: readonly Point[]; + readonly width?: number | null; + readonly height?: number | null; + readonly wallHeight?: number; + readonly plinthHeight?: number; + readonly plinthThickness?: number; + readonly order?: number; + readonly posX?: number; + readonly posY?: number; + readonly floorType?: FloorType; + readonly wallColor?: string; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..e459899 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..b33bef1 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + passWithNoTests: true, + }, +}); diff --git a/plans/house-plan-maker/CONTEXT.md b/plans/house-plan-maker/CONTEXT.md new file mode 100644 index 0000000..45ea750 --- /dev/null +++ b/plans/house-plan-maker/CONTEXT.md @@ -0,0 +1,50 @@ +# Feature Context: House/Apartment Plan Maker + +## Configuration +- **Development mode:** Automated +- **Execution mode:** Orchestrator +- **Strategy:** Big Bang +- **Build:** `npx turbo build` +- **Test:** `npx turbo test` +- **Lint:** `npx turbo lint` +- **Dev:** `npx turbo dev` + +## Current State +Greenfield project — no existing code. Starting from empty repository with only PLAN_PROMPT.md. + +## Temporary Workarounds +(none yet) + +## Cross-Phase Dependencies +- Phase 2 (API) depends on Phase 1 (scaffold + Prisma setup) +- Phase 3 (UI) depends on Phase 2 (API endpoints to connect to) +- Phase 4 (2D Editor) depends on Phase 3 (room navigation + component structure) +- Phase 5 (Electrical/Furniture) depends on Phase 4 (canvas + tool system) +- Phase 6 (Projections) depends on Phase 4 (room data model in editor) +- Phase 7 (3D View) depends on Phase 4 (room geometry data) +- Phase 6 and Phase 7 are INDEPENDENT of each other — can run in parallel +- Phase 8 (Export + Polish) depends on all prior phases + +## Deferred Work +(none yet) + +## Failed Approaches +(none yet) + +## Review Findings Log +(none yet) + +## Phase Execution Log +| Phase | Agent Used | Test Writer | Parallel | Notes | +|-------|-----------|-------------|----------|-------| +| (not started) | | | | | + +## Environment & Runtime Notes +- Windows 10 platform +- Git Bash shell +- Node.js required (version TBD by Phase 1) + +## Implementation Notes +- Big Bang strategy: intermediate phases may break the build. Tests deferred to final phase. +- All agents use Opus model for maximum reasoning depth. +- Frontend phases use phase-implementer-frontend agent; backend/fullstack use phase-implementer. diff --git a/plans/house-plan-maker/PLAN.md b/plans/house-plan-maker/PLAN.md new file mode 100644 index 0000000..3386aca --- /dev/null +++ b/plans/house-plan-maker/PLAN.md @@ -0,0 +1,71 @@ +# Feature: House/Apartment Plan Maker + +**Branch:** `feature/house-plan-maker` +**Base branch:** `master` +**Created:** 2026-04-05 +**Status:** 🟡 In Progress +**Strategy:** Big Bang +**Mode:** Automated +**Execution:** Orchestrator + +## Summary +Greenfield client-server application for creating and editing apartment/house floor plans. +Features: Apartment CRUD, room editor with 2D top-down view, 4 wall projection views, +3D perspective preview, electrical planning (IEC symbols), furniture placement, REST API +with persistence, and PDF/PNG export. + +## Tech Stack +- **Frontend:** React 19 + TypeScript + Vite +- **2D Canvas:** Konva.js via react-konva +- **3D View:** Three.js via @react-three/fiber + @react-three/drei +- **Backend:** Node.js + Fastify + Prisma ORM +- **Database:** SQLite (dev) / PostgreSQL (prod) +- **Export:** jsPDF + html2canvas (client-side) +- **Monorepo:** Turborepo: `apps/client`, `apps/server`, `packages/shared` +- **Testing:** Vitest (unit/integration) + Playwright (E2E) + +## Build & Test Commands +- **Build:** `npx turbo build` +- **Test:** `npx turbo test` +- **Lint:** `npx turbo lint` +- **Dev:** `npx turbo dev` + +## Phases + +- [x] Phase 1: Monorepo Scaffold + Tooling [domain: fullstack] → [subplan](./phase-1-scaffold.md) +- [x] Phase 2: Data Model + REST API [domain: backend] → [subplan](./phase-2-data-model-api.md) +- [x] Phase 3: Apartment & Room Management UI [domain: frontend] → [subplan](./phase-3-management-ui.md) +- [x] Phase 4: 2D Top-Down Room Editor — Core [domain: frontend] → [subplan](./phase-4-2d-editor-core.md) +- [x] Phase 5: Electrical & Furniture Placement [domain: frontend] → [subplan](./phase-5-electrical-furniture.md) +- [x] Phase 6: Wall Projection Views [domain: frontend] → [subplan](./phase-6-wall-projections.md) +- [x] Phase 7: 3D Perspective View [domain: frontend] → [subplan](./phase-7-3d-view.md) +- [x] Phase 8: Export Pipeline + Polish [domain: fullstack] → [subplan](./phase-8-export-polish.md) + +**Parallel note:** Phases 6 and 7 are independent and will be executed in parallel. + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +|-------|--------|--------|--------|-------|-----------| +| Phase 1: Scaffold | fullstack | ✅ Done | ✅ | ⏭️ Skipped (BB) | ✅ | +| Phase 2: Data Model + API | backend | ✅ Done | ✅ | ⏭️ Skipped (BB) | ✅ | +| Phase 3: Management UI | frontend | ✅ Done | ✅ | ⏭️ Skipped (BB) | ✅ | +| Phase 4: 2D Editor Core | frontend | ✅ Done | ✅ | ⏭️ Skipped (BB) | ✅ | +| Phase 5: Electrical & Furniture | frontend | ✅ Done | ✅ | ⏭️ Skipped (BB) | ✅ | +| Phase 6: Wall Projections | frontend | ✅ Done | ✅ | ⏭️ Skipped (BB) | ✅ | +| Phase 7: 3D View | frontend | ✅ Done | ✅ | ⏭️ Skipped (BB) | ✅ | +| Phase 8: Export + Polish | fullstack | ✅ Done | ⬜ | ✅ Pass | ✅ | + +## Amendment Log + +### Amendment 1 — 2026-04-05 +**Type:** Modified data model +**What changed:** Added `plinthHeight` (default 0.06m) and `plinthThickness` (default 0.01m) fields to the Room model across Prisma schema, shared types, Zod schemas, and room routes. +**Why:** User requested plinth (baseboard/skirting board) visualization. Plinth dimensions are a room-level property. +**Impact on existing phases:** Phase 6 (Wall Projections) and Phase 7 (3D View) should render plinth. Phase 3 (Room Form) should include plinth fields. + +## Final Review +- [ ] Comprehensive code review +- [ ] Full build passes +- [ ] Full test suite passes +- [ ] Merged to `master` diff --git a/plans/house-plan-maker/phase-1-scaffold.md b/plans/house-plan-maker/phase-1-scaffold.md new file mode 100644 index 0000000..a7e9f46 --- /dev/null +++ b/plans/house-plan-maker/phase-1-scaffold.md @@ -0,0 +1,82 @@ +# Phase 1: Monorepo Scaffold + Tooling + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Set up the Turborepo monorepo with all three packages, configure TypeScript, linting, testing, and development tooling. After this phase, `turbo dev` should start both client and server. + +## Tasks + +- [x] Task 1: Initialize Turborepo monorepo at project root with `turbo.json` +- [x] Task 2: Create `apps/client` — React 19 + TypeScript + Vite app with basic App component +- [x] Task 3: Create `apps/server` — Fastify + TypeScript server with health check endpoint (`GET /api/health`) +- [x] Task 4: Create `packages/shared` — TypeScript package for shared types, exported as ESM +- [x] Task 5: Configure root `package.json` with workspaces, scripts (`dev`, `build`, `test`, `lint`) +- [x] Task 6: Configure TypeScript — root `tsconfig.json` with project references, per-package `tsconfig.json` +- [x] Task 7: Configure ESLint + Prettier — root config with React plugin for client, Node plugin for server +- [x] Task 8: Configure Vitest — per-package test setup, root `turbo.json` pipeline for `test` task +- [x] Task 9: Set up Prisma in `apps/server` — initialize with SQLite provider, empty schema +- [x] Task 10: Configure Vite proxy — proxy `/api/*` requests from client dev server to Fastify server +- [x] Task 11: Verify `turbo dev` starts both client (port 5173) and server (port 3001) simultaneously +- [x] Task 12: Add `.gitignore` covering node_modules, dist, .env, prisma SQLite files + +## Files to Modify/Create +- `package.json` — root workspace config +- `turbo.json` — Turborepo pipeline config +- `tsconfig.json` — root TypeScript config +- `eslint.config.js` — root ESLint flat config (ESLint 9 uses flat config, not `.eslintrc.cjs`) +- `.prettierrc` — Prettier config +- `.gitignore` — ignore patterns +- `apps/client/package.json` — React + Vite dependencies +- `apps/client/tsconfig.json` — client TypeScript config +- `apps/client/vite.config.ts` — Vite config with API proxy +- `apps/client/vitest.config.ts` — Vitest config for client +- `apps/client/index.html` — entry HTML +- `apps/client/src/main.tsx` — React entry point +- `apps/client/src/App.tsx` — root App component +- `apps/server/package.json` — Fastify dependencies +- `apps/server/tsconfig.json` — server TypeScript config +- `apps/server/vitest.config.ts` — Vitest config for server +- `apps/server/src/index.ts` — Fastify server entry with health endpoint +- `apps/server/prisma/schema.prisma` — initial Prisma schema (SQLite, empty models) +- `apps/server/.env` — DATABASE_URL for SQLite +- `packages/shared/package.json` — shared types package config +- `packages/shared/tsconfig.json` — shared TypeScript config +- `packages/shared/vitest.config.ts` — Vitest config for shared +- `packages/shared/src/index.ts` — barrel export + +## Acceptance Criteria +- [x] `npm install` at root resolves all workspaces +- [x] `turbo dev` starts client on :5173 and server on :3001 +- [x] `GET /api/health` returns `{ status: "ok" }` from Fastify +- [x] Client renders a basic "House Plan Maker" heading +- [x] TypeScript compiles without errors across all packages +- [x] ESLint and Prettier are configured and runnable +- [x] Vitest is configured and runs (even with 0 tests) + +## Notes +- Using Node.js 22 (available on system) +- SQLite for development simplicity — PostgreSQL adapter can be swapped later +- Prisma schema starts empty — models added in Phase 2 +- Vite proxy avoids CORS issues during development +- ESLint 9 flat config used instead of legacy `.eslintrc.cjs` (ESLint 9 default) + +## Review Checklist +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects +- [x] Build passes +- [x] Tests pass (new + existing) + +## Handoff to Next Phase +Phase 1 scaffold is complete. The monorepo is fully operational with: +- Turborepo managing 3 packages (`@house-plan-maker/client`, `@house-plan-maker/server`, `@house-plan-maker/shared`) +- All commands work: `turbo dev`, `turbo build`, `turbo test`, `turbo lint` +- Server health endpoint returns `{ status: "ok", timestamp: "..." }` +- Client renders "House Plan Maker" heading +- Prisma initialized with SQLite, ready for models in Phase 2 +- Shared package exports `HealthCheckResponse` type used by server + +Phase 2 can proceed to add the data model (Prisma schema), API endpoints, and basic CRUD UI. diff --git a/plans/house-plan-maker/phase-2-data-model-api.md b/plans/house-plan-maker/phase-2-data-model-api.md new file mode 100644 index 0000000..8aaec69 --- /dev/null +++ b/plans/house-plan-maker/phase-2-data-model-api.md @@ -0,0 +1,138 @@ +# Phase 2: Data Model + REST API + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Design and implement the complete data model (Prisma schema) and REST API endpoints for apartments, rooms, and all element types. Define shared types in `packages/shared`. + +## Tasks + +- [ ] Task 1: Design Prisma schema with models: Apartment, Room, Wall, WallOpening (doors/windows), ElectricalItem, FurnitureItem +- [ ] Task 2: Define shared TypeScript types in `packages/shared` matching Prisma models + API request/response DTOs +- [ ] Task 3: Implement Zod validation schemas in `packages/shared` for all create/update operations +- [ ] Task 4: Set up Prisma client generation and migration workflow in `apps/server` +- [ ] Task 5: Implement Apartment CRUD endpoints: `GET/POST /api/apartments`, `GET/PUT/DELETE /api/apartments/:id` +- [ ] Task 6: Implement Room CRUD endpoints: `GET/POST /api/apartments/:id/rooms`, `GET/PUT/DELETE /api/rooms/:id` +- [ ] Task 7: Implement Element endpoints for walls: `PUT /api/rooms/:id/walls` (bulk update room walls) +- [ ] Task 8: Implement Element endpoints for wall openings: `POST/PUT/DELETE /api/rooms/:id/openings` +- [ ] Task 9: Implement Electrical item CRUD: `GET/POST /api/rooms/:id/electrical`, `PUT/DELETE /api/electrical/:id` +- [ ] Task 10: Implement Furniture item CRUD: `GET/POST /api/rooms/:id/furniture`, `PUT/DELETE /api/furniture/:id` +- [ ] Task 11: Implement `GET /api/rooms/:id/full` — returns room with ALL elements (walls, openings, electrical, furniture) in a single response +- [ ] Task 12: Add Fastify error handling plugin — consistent error response format `{ error: string, statusCode: number }` +- [ ] Task 13: Register all routes using Fastify route plugin pattern (separate route files per resource) + +## Data Model Design + +``` +Apartment + - id: string (cuid) + - name: string + - address: string? + - totalArea: float? + - createdAt: DateTime + - updatedAt: DateTime + - rooms: Room[] + +Room + - id: string (cuid) + - apartmentId: string (FK) + - name: string + - shape: JSON (array of {x, y} points defining the polygon) + - width: float? (convenience for rectangular rooms) + - height: float? (convenience for rectangular rooms) + - wallHeight: float (default 2.7m) + - order: int (display order) + - createdAt: DateTime + - updatedAt: DateTime + - walls: Wall[] + - openings: WallOpening[] + - electricalItems: ElectricalItem[] + - furnitureItems: FurnitureItem[] + +Wall + - id: string (cuid) + - roomId: string (FK) + - startX: float + - startY: float + - endX: float + - endY: float + - thickness: float (default 0.1m) + - direction: enum (NORTH, SOUTH, EAST, WEST, OTHER) + +WallOpening + - id: string (cuid) + - roomId: string (FK) + - wallId: string (FK) + - type: enum (DOOR, WINDOW) + - positionAlongWall: float (0-1 normalized position) + - width: float + - height: float + - elevationFromFloor: float (for windows) + +ElectricalItem + - id: string (cuid) + - roomId: string (FK) + - type: enum (OUTLET, SWITCH, JUNCTION_BOX, LIGHT_CEILING, LIGHT_WALL, CABLE_ROUTE) + - x: float + - y: float + - wallId: string? (FK, for wall-mounted items) + - elevationFromFloor: float? + - rotation: float (degrees) + - metadata: JSON? (extra properties per type) + +FurnitureItem + - id: string (cuid) + - roomId: string (FK) + - type: enum (BED, DESK, WARDROBE, SOFA, TABLE, CHAIR, SHELF, NIGHTSTAND, DRESSER, BOOKCASE, OTHER) + - x: float + - y: float + - width: float + - depth: float + - height: float + - rotation: float (degrees) + - label: string? +``` + +## Files to Modify/Create +- `apps/server/prisma/schema.prisma` — complete data model +- `packages/shared/src/types/apartment.ts` — Apartment types +- `packages/shared/src/types/room.ts` — Room types +- `packages/shared/src/types/elements.ts` — Wall, WallOpening, ElectricalItem, FurnitureItem types +- `packages/shared/src/types/api.ts` — API request/response wrapper types +- `packages/shared/src/schemas/apartment.schema.ts` — Zod schemas for Apartment +- `packages/shared/src/schemas/room.schema.ts` — Zod schemas for Room +- `packages/shared/src/schemas/elements.schema.ts` — Zod schemas for elements +- `packages/shared/src/index.ts` — barrel exports (update) +- `apps/server/src/plugins/prisma.ts` — Prisma client Fastify plugin +- `apps/server/src/plugins/error-handler.ts` — error handling plugin +- `apps/server/src/routes/apartments.ts` — apartment route handlers +- `apps/server/src/routes/rooms.ts` — room route handlers +- `apps/server/src/routes/elements.ts` — wall, opening, electrical, furniture route handlers +- `apps/server/src/index.ts` — register route plugins (update) + +## Acceptance Criteria +- Prisma schema validates and migrations generate successfully +- All CRUD endpoints return correct responses for happy paths +- Validation rejects malformed requests with clear error messages +- `GET /api/rooms/:id/full` returns nested room with all elements +- Shared types are importable from `@house-plan-maker/shared` +- Zod schemas match Prisma model constraints + +## Notes +- Room shape stored as JSON array of polygon points — flexible for both rectangular and arbitrary shapes +- Wall direction enum helps with projection view mapping (Phase 6) +- ElectricalItem.wallId is nullable — ceiling-mounted items have no wall +- Use Prisma's cascade delete: deleting an apartment deletes its rooms, elements, etc. +- FurnitureItem dimensions are in meters + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/house-plan-maker/phase-3-management-ui.md b/plans/house-plan-maker/phase-3-management-ui.md new file mode 100644 index 0000000..7a22ae6 --- /dev/null +++ b/plans/house-plan-maker/phase-3-management-ui.md @@ -0,0 +1,71 @@ +# Phase 3: Apartment & Room Management UI + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Build the apartment and room management pages — list, create, edit, delete apartments; list, create, edit, delete rooms within an apartment. Establish the frontend design system (colors, typography, layout patterns) and API client layer. + +## Tasks + +- [ ] Task 1: Set up React Router v7 with route structure: `/` (apartments list), `/apartments/:id` (apartment detail with rooms), `/apartments/:id/rooms/:roomId/editor` (placeholder for Phase 4) +- [ ] Task 2: Create API client module using `fetch` — typed request/response functions for all endpoints from Phase 2 +- [ ] Task 3: Create a shared UI component library: Button, Input, Card, Modal, EmptyState, LoadingSpinner, ErrorBanner +- [ ] Task 4: Establish design tokens as CSS variables: colors (neutral + accent palette), spacing scale, typography scale, border radii, shadows +- [ ] Task 5: Create `ApartmentListPage` — displays apartments as cards with name, address, area, room count. "New Apartment" button. +- [ ] Task 6: Create `ApartmentFormModal` — form for creating/editing apartment (name, address, total area). Zod validation. +- [ ] Task 7: Create `ApartmentDetailPage` — shows apartment info header + list of rooms as cards. Each room card shows name, dimensions, shape preview (simple SVG outline). "Add Room" button. +- [ ] Task 8: Create `RoomFormModal` — form for creating/editing room (name, shape type selector: rectangular vs custom polygon, width/height for rectangular, wall height, plinth height, plinth thickness). For rectangular, auto-generate polygon points from width/height. +- [ ] Task 9: Implement delete confirmation dialog for apartments and rooms +- [ ] Task 10: Add navigation: clicking a room card navigates to `/apartments/:id/rooms/:roomId/editor` (shows placeholder "Editor coming in Phase 4") +- [ ] Task 11: Add responsive layout — sidebar navigation on desktop, bottom nav on mobile. App shell with header showing current location breadcrumbs. +- [ ] Task 12: Wire all UI to API client — loading states, error handling, optimistic updates where appropriate + +## Files to Modify/Create +- `apps/client/src/main.tsx` — add RouterProvider +- `apps/client/src/router.tsx` — route definitions +- `apps/client/src/styles/tokens.css` — CSS custom properties (design tokens) +- `apps/client/src/styles/global.css` — global reset and base styles +- `apps/client/src/api/client.ts` — typed API client functions +- `apps/client/src/components/ui/Button.tsx` — shared Button component +- `apps/client/src/components/ui/Input.tsx` — shared Input component +- `apps/client/src/components/ui/Card.tsx` — shared Card component +- `apps/client/src/components/ui/Modal.tsx` — shared Modal component +- `apps/client/src/components/ui/EmptyState.tsx` — empty state placeholder +- `apps/client/src/components/ui/LoadingSpinner.tsx` — loading indicator +- `apps/client/src/components/ui/ErrorBanner.tsx` — error display +- `apps/client/src/components/layout/AppShell.tsx` — app shell with header, sidebar, breadcrumbs +- `apps/client/src/pages/ApartmentListPage.tsx` — apartment list +- `apps/client/src/pages/ApartmentDetailPage.tsx` — apartment detail with rooms +- `apps/client/src/pages/RoomEditorPage.tsx` — placeholder for Phase 4 +- `apps/client/src/components/apartments/ApartmentCard.tsx` — apartment card +- `apps/client/src/components/apartments/ApartmentFormModal.tsx` — create/edit form +- `apps/client/src/components/rooms/RoomCard.tsx` — room card with shape preview +- `apps/client/src/components/rooms/RoomFormModal.tsx` — create/edit room form +- `apps/client/src/components/shared/ConfirmDialog.tsx` — delete confirmation + +## Acceptance Criteria +- User can create, view, edit, and delete apartments +- User can create, view, edit, and delete rooms within an apartment +- Rectangular rooms auto-generate polygon shape from width/height +- Navigation works: apartment list → apartment detail → room editor (placeholder) +- All API interactions show loading states and handle errors gracefully +- Design is clean, consistent, and responsive +- All UI uses the design token system (no hardcoded colors/sizes) + +## Notes +- Keep the design professional and clean — this is a tool for professionals (electricians, furniture makers) +- Room shape preview on the card is a simple SVG polygon outline — not the full editor +- The editor placeholder page should show room name and a message like "2D Editor loading in next phase" +- Use CSS Modules or plain CSS with design tokens (no Tailwind in this project) + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/house-plan-maker/phase-4-2d-editor-core.md b/plans/house-plan-maker/phase-4-2d-editor-core.md new file mode 100644 index 0000000..32272c9 --- /dev/null +++ b/plans/house-plan-maker/phase-4-2d-editor-core.md @@ -0,0 +1,116 @@ +# Phase 4: 2D Top-Down Room Editor — Core + +**Status:** :white_check_mark: Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Build the core 2D top-down room editor using react-konva. This is the primary editing surface: grid, zoom/pan, wall rendering, tool system (select, door, window placement), snap-to-grid, undo/redo, and save to API. + +## Tasks + +- [x] Task 1: Set up react-konva Stage in `RoomEditorPage` — full-viewport canvas with zoom (scroll wheel) and pan (middle mouse / two-finger drag) +- [x] Task 2: Implement grid layer — configurable grid size (default 0.1m), visual grid lines, scale ruler on edges showing meters +- [x] Task 3: Render room walls from polygon shape data — closed polygon of wall segments, fill room interior with light color +- [x] Task 4: Implement tool system architecture — `EditorContext` with active tool state, tool-specific cursor, tool-specific click/drag handlers +- [x] Task 5: Implement Select tool — click to select elements, multi-select with Shift+click or drag rectangle, selection bounding box with resize handles +- [x] Task 6: Implement Door placement tool — click on a wall to place a door, door renders as an arc symbol in the wall, configurable width +- [x] Task 7: Implement Window placement tool — click on a wall to place a window, window renders as parallel lines in the wall, configurable width + height +- [x] Task 8: Implement snap-to-grid — `useSnapping` hook with configurable snap granularity +- [x] Task 9: Implement snap-to-wall — doors and windows snap to nearest wall segment when placed near one (built into DoorTool/WindowTool via `findNearestWall`) +- [x] Task 10: Implement undo/redo system — command pattern stack (`UndoRedoContext`). Keyboard shortcuts: Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y +- [x] Task 11: Implement editor toolbar — horizontal toolbar above canvas with tool buttons (Select, Door, Window), undo/redo buttons, zoom controls, grid toggle, snap toggle, save button +- [x] Task 12: Implement properties panel — right sidebar showing properties of selected element(s). Editable fields for width, height, position, elevation. +- [x] Task 13: Implement canvas layer system — separate Konva Layers for: grid, walls, openings, electrical (placeholder), furniture (placeholder), measurements, selection overlay +- [x] Task 14: Implement save/load — save current room state (walls, openings) to API. Load via `getRoomFull` on page mount. +- [x] Task 15: Add keyboard shortcuts — Delete (remove selected), Escape (deselect/cancel), Ctrl+A (select all), Ctrl+S (save), V/D/W (tool shortcuts) +- [x] Task 16: Implement measurement annotations — auto-display wall lengths, room dimensions. Show distance from selected opening to wall ends. Auto-hide at low zoom. + +## Files to Modify/Create +- `apps/client/src/pages/RoomEditorPage.tsx` — replace placeholder with full editor layout +- `apps/client/src/components/editor/EditorCanvas.tsx` — react-konva Stage with zoom/pan +- `apps/client/src/components/editor/layers/GridLayer.tsx` — grid rendering +- `apps/client/src/components/editor/layers/WallLayer.tsx` — wall polygon rendering +- `apps/client/src/components/editor/layers/OpeningLayer.tsx` — doors and windows on walls +- `apps/client/src/components/editor/layers/SelectionLayer.tsx` — selection overlay, handles +- `apps/client/src/components/editor/layers/MeasurementLayer.tsx` — dimension annotations +- `apps/client/src/components/editor/tools/SelectTool.ts` — select tool logic +- `apps/client/src/components/editor/tools/DoorTool.ts` — door placement logic +- `apps/client/src/components/editor/tools/WindowTool.ts` — window placement logic +- `apps/client/src/components/editor/EditorToolbar.tsx` — toolbar UI +- `apps/client/src/components/editor/PropertiesPanel.tsx` — properties sidebar +- `apps/client/src/components/editor/context/EditorContext.tsx` — editor state context (room data, selected elements, active tool) +- `apps/client/src/components/editor/context/UndoRedoContext.tsx` — undo/redo command stack +- `apps/client/src/components/editor/hooks/useEditorZoom.ts` — zoom/pan logic +- `apps/client/src/components/editor/hooks/useSnapping.ts` — snap-to-grid and snap-to-wall +- `apps/client/src/components/editor/hooks/useKeyboardShortcuts.ts` — keyboard shortcut handler +- `apps/client/src/components/editor/utils/geometry.ts` — geometry helpers (point-on-wall, distance, intersection) +- `apps/client/src/components/editor/utils/wallUtils.ts` — wall-specific geometry (find nearest wall, position along wall) + +## Acceptance Criteria +- Canvas renders room walls from polygon data with correct scale +- Grid displays with configurable size and scale ruler +- Zoom and pan work smoothly (mouse wheel + drag) +- Select tool allows selecting, moving, and multi-selecting elements +- Doors and windows can be placed on walls and snap correctly +- Undo/redo works for all operations (add, move, delete, modify) +- Properties panel shows and allows editing selected element properties +- Editor state saves to and loads from the API +- Keyboard shortcuts work (Ctrl+Z, Delete, Escape, Ctrl+S) +- Measurement annotations show wall lengths and room dimensions + +## Notes +- Konva uses a Stage > Layer > Shape hierarchy. Each Layer gets its own canvas for performance. +- Grid should use a repeating pattern rather than individual lines for performance at high zoom +- All coordinates are in meters (matching the data model). Canvas pixel-to-meter conversion via zoom level. +- The tool system should be extensible — Phase 5 adds electrical and furniture tools +- Undo/redo uses command pattern: each command has `execute()` and `undo()` methods +- Measurement annotations should auto-hide at low zoom levels to avoid clutter +- Electrical and furniture layers are created but empty — placeholders for Phase 5 + +## Review Checklist +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Phase 5 + +### What was built + +Phase 4 implemented the complete 2D top-down room editor with react-konva. The editor is a full-page layout with toolbar on top, canvas in center, and properties panel on right. + +### Architecture + +- **Editor state** is managed via `EditorContext` (React context + reducer pattern). All state is immutable. +- **Undo/redo** uses a command pattern via `UndoRedoContext`. Each command has `execute()` and `undo()` methods. +- **Canvas layers** are separate Konva `` components (7 layers): grid, walls, openings, electrical (placeholder), furniture (placeholder), measurements, selection. +- **Tool system** is driven by `activeTool` in EditorContext. Tool logic lives in `tools/*.ts` files as pure functions. The `EditorCanvas` dispatches to the active tool in its mouse event handlers. +- **Snapping** is implemented in `hooks/useSnapping.ts` with snap-to-grid and snap-to-wall. Door/window tools have their own wall snapping built into their `compute*Preview` functions. + +### How to add new tools (Phase 5) + +1. Add the tool type to `EditorToolType` union in `types.ts` (e.g., `'electrical'` and `'furniture'` are already there). +2. Create a tool file in `tools/` with preview computation and element creation functions. +3. Add tool button to `TOOLS` array in `EditorToolbar.tsx`. +4. Add tool cursor to `TOOL_CURSORS` in `EditorCanvas.tsx`. +5. Add mouse event handlers for the new tool in `EditorCanvas.tsx`'s `handleMouseDown` and `handleMouseMove` callbacks. +6. Implement the corresponding layer in `layers/ElectricalLayer.tsx` or `layers/FurnitureLayer.tsx` (currently empty placeholders). +7. Add `EditorAction` variants if needed in `types.ts` and handle them in the reducer in `EditorContext.tsx`. + +### API functions added + +`apps/client/src/api/client.ts` now exports: + +- `bulkUpdateWalls(roomId, walls)` — PUT /api/rooms/:id/walls +- `createWallOpening(roomId, data)` — POST /api/rooms/:id/openings +- `updateWallOpening(roomId, openingId, data)` — PUT /api/rooms/:id/openings/:openingId +- `deleteWallOpening(roomId, openingId)` — DELETE /api/rooms/:id/openings/:openingId + +### Known limitations / future improvements + +- **Element dragging** (moving selected elements by dragging) is not yet implemented. The select tool supports click-select and drag-rectangle multi-select, but not drag-to-move. +- **Resize handles** on the selection bounding box are rendered but not interactive (no drag-to-resize). +- **Grid performance** at very high zoom could be improved by switching to a repeating canvas pattern instead of individual Line shapes. +- **Two-finger pan** on trackpad is not implemented (only middle-mouse-button pan). Could be added by detecting `e.evt.ctrlKey` on wheel events (pinch-to-zoom) or touch events. diff --git a/plans/house-plan-maker/phase-5-electrical-furniture.md b/plans/house-plan-maker/phase-5-electrical-furniture.md new file mode 100644 index 0000000..58de276 --- /dev/null +++ b/plans/house-plan-maker/phase-5-electrical-furniture.md @@ -0,0 +1,87 @@ +# Phase 5: Electrical & Furniture Placement + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Add electrical symbol library (IEC standard) and furniture silhouette library to the 2D editor. Implement drag-and-drop placement, layer toggling, collision detection, and save/load for both element types. + +## Tasks + +- [ ] Task 1: Create IEC electrical symbol library as SVG/Konva shapes: outlet (single, double, grounded), switch (single, double, dimmer), junction box, ceiling light, wall light, cable route (line with markers) +- [ ] Task 2: Create furniture silhouette library as Konva shapes with approximate dimensions (meters): bed (1.4x2.0, 1.6x2.0, 1.8x2.0), desk (1.2x0.6), wardrobe (varies), sofa (2.0x0.9), dining table (1.2x0.8), chair (0.45x0.45), shelf (0.8x0.3), nightstand (0.5x0.4), dresser (1.0x0.5), bookcase (0.8x0.3) +- [ ] Task 3: Implement electrical tool — click to place electrical items on canvas. Wall-mounted items (outlets, switches, wall lights) snap to nearest wall. Ceiling items (ceiling light, junction box) place freely. Cable routes draw as connected line segments. +- [ ] Task 4: Implement furniture tool — drag from furniture palette to canvas. Furniture renders as recognizable top-down silhouette with label. Rotation handle on selected furniture. +- [ ] Task 5: Create electrical palette panel — left sidebar panel showing all electrical symbol types grouped by category. Click to select type, then click on canvas to place. +- [ ] Task 6: Create furniture palette panel — left sidebar panel showing all furniture types with icons and default dimensions. Drag from palette to canvas. +- [ ] Task 7: Implement layer toggling — toolbar buttons to show/hide: walls, electrical, furniture, measurements. Visual indicator for active/hidden layers. +- [ ] Task 8: Implement collision detection for furniture — highlight overlapping furniture in red. Optional warning, not blocking (user may intentionally stack items). +- [ ] Task 9: Implement light coverage visualization — when a light fixture is selected, show a semi-transparent circle indicating its coverage radius. Radius configurable in properties panel. +- [ ] Task 10: Add electrical and furniture items to the properties panel — show type-specific properties (e.g., outlet grounding, switch type, furniture dimensions, custom labels) +- [ ] Task 11: Implement save/load for electrical and furniture items — use the API endpoints from Phase 2 +- [ ] Task 12: Register electrical and furniture tools with the existing tool system from Phase 4 +- [ ] Task 13: Add cable length calculator — display total cable length based on all cable route segments in the room. Show in a status bar at the bottom of the editor. + +## Electrical Symbol Specifications (IEC 60617) +- **Outlet:** Circle with two parallel lines (grounded: + earth symbol) +- **Switch:** Circle with a line at angle (dimmer: arc symbol) +- **Junction box:** Filled circle or square with X +- **Ceiling light:** Circle with cross pattern +- **Wall light:** Half-circle against wall +- **Cable route:** Dashed line connecting elements + +## Files to Modify/Create +- `apps/client/src/components/editor/symbols/electrical/OutletSymbol.tsx` — outlet variations +- `apps/client/src/components/editor/symbols/electrical/SwitchSymbol.tsx` — switch variations +- `apps/client/src/components/editor/symbols/electrical/JunctionBoxSymbol.tsx` +- `apps/client/src/components/editor/symbols/electrical/CeilingLightSymbol.tsx` +- `apps/client/src/components/editor/symbols/electrical/WallLightSymbol.tsx` +- `apps/client/src/components/editor/symbols/electrical/CableRouteSymbol.tsx` +- `apps/client/src/components/editor/symbols/electrical/index.ts` — symbol registry +- `apps/client/src/components/editor/symbols/furniture/BedSilhouette.tsx` +- `apps/client/src/components/editor/symbols/furniture/DeskSilhouette.tsx` +- `apps/client/src/components/editor/symbols/furniture/WardrobeSilhouette.tsx` +- `apps/client/src/components/editor/symbols/furniture/SofaSilhouette.tsx` +- `apps/client/src/components/editor/symbols/furniture/TableSilhouette.tsx` +- `apps/client/src/components/editor/symbols/furniture/ChairSilhouette.tsx` +- `apps/client/src/components/editor/symbols/furniture/ShelfSilhouette.tsx` +- `apps/client/src/components/editor/symbols/furniture/index.ts` — furniture registry +- `apps/client/src/components/editor/layers/ElectricalLayer.tsx` — render electrical items +- `apps/client/src/components/editor/layers/FurnitureLayer.tsx` — render furniture items +- `apps/client/src/components/editor/tools/ElectricalTool.ts` — electrical placement logic +- `apps/client/src/components/editor/tools/FurnitureTool.ts` — furniture placement logic +- `apps/client/src/components/editor/panels/ElectricalPalette.tsx` — electrical symbol picker +- `apps/client/src/components/editor/panels/FurniturePalette.tsx` — furniture item picker +- `apps/client/src/components/editor/panels/CableLengthStatus.tsx` — cable length calculator display +- `apps/client/src/components/editor/utils/collisionDetection.ts` — furniture overlap detection +- `apps/client/src/components/editor/utils/lightCoverage.ts` — light radius calculation + +## Acceptance Criteria +- All IEC electrical symbols render correctly and are recognizable +- All furniture silhouettes render as recognizable top-down shapes +- Electrical items can be placed on the canvas — wall-mounted items snap to walls +- Furniture items can be dragged from palette to canvas with rotation support +- Layer toggling shows/hides element categories independently +- Collision detection highlights overlapping furniture +- Light coverage circles display for selected light fixtures +- Cable length calculator shows total cable route length +- All items save to and load from API +- Properties panel works for electrical and furniture items + +## Notes +- Electrical symbols should be precise enough for electricians to understand the plan +- Furniture silhouettes should be recognizable but not photorealistic — clean geometric shapes +- Cable route is a series of connected line segments, not a single straight line +- Collision detection is visual feedback only — not a hard constraint +- Light coverage radius is configurable per fixture (default: 3m for ceiling, 1.5m for wall) + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/house-plan-maker/phase-6-wall-projections.md b/plans/house-plan-maker/phase-6-wall-projections.md new file mode 100644 index 0000000..6622d2e --- /dev/null +++ b/plans/house-plan-maker/phase-6-wall-projections.md @@ -0,0 +1,75 @@ +# Phase 6: Wall Projection Views + +**Status:** :white_check_mark: Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Implement 4 side projection views (North, South, East, West walls) showing wall elevation. Each projection shows the wall face with doors, windows, outlets, switches, and furniture against that wall at their correct positions and heights. + +## Tasks + +- [x] Task 1: Create `WallProjectionView` component — renders a single wall's elevation view using react-konva. Shows wall rectangle (width = wall length, height = wallHeight), floor line, ceiling line. +- [x] Task 2: Implement projection view layout — 4 projection views arranged below the 2D top-down view. Tabs or grid layout to switch between walls. Collapsible panel. +- [x] Task 3: Render doors in projection — show door opening in wall elevation (rectangle from floor, standard door height 2.1m). Door swing indicator. +- [x] Task 4: Render windows in projection — show window in wall elevation at correct elevation from floor. Window frame with glass indication. +- [x] Task 5: Render wall-mounted electrical items — outlets and switches at their correct height (elevationFromFloor). Use simplified IEC symbols oriented for wall view. +- [x] Task 6: Render wall-mounted furniture — shelves, wall cabinets displayed at correct height and position along wall. +- [x] Task 7: Implement wall-to-projection coordinate mapping — translate 2D top-down coordinates to projection view coordinates. +- [x] Task 8: Implement projection-to-2D sync — clicking an item in projection view highlights it in 2D view and vice versa. +- [x] Task 9: Add measurement annotations to projection views — heights of electrical items, window sill height, door height, distances between items. +- [x] Task 10: Add wall selection highlighting — when a wall is selected in 2D view, highlight the corresponding projection view. +- [x] Task 11: Implement projection view zoom/pan — independent zoom for each projection view. +- [x] Task 12: Render plinth (baseboard) — draw a strip at the base of each wall in projection view using room's `plinthHeight` and `plinthThickness`. Plinth should be interrupted at door openings. + +## Files to Modify/Create +- `apps/client/src/components/editor/projection/WallProjectionView.tsx` — single wall elevation canvas +- `apps/client/src/components/editor/projection/ProjectionPanel.tsx` — panel containing 4 wall projections +- `apps/client/src/components/editor/projection/ProjectionDoor.tsx` — door in elevation view +- `apps/client/src/components/editor/projection/ProjectionWindow.tsx` — window in elevation view +- `apps/client/src/components/editor/projection/ProjectionElectrical.tsx` — electrical items in elevation +- `apps/client/src/components/editor/projection/ProjectionFurniture.tsx` — wall furniture in elevation +- `apps/client/src/components/editor/projection/ProjectionMeasurements.tsx` — height/distance annotations +- `apps/client/src/components/editor/utils/projectionMapping.ts` — 2D to projection coordinate conversion +- `apps/client/src/components/editor/RoomEditorLayout.tsx` — integrate projection panel (updated) + +## Acceptance Criteria +- 4 wall projection views display correctly for rectangular rooms +- Doors and windows appear at correct positions and dimensions in projections +- Electrical items (outlets, switches) appear at correct wall positions and heights +- Wall-mounted furniture appears at correct positions +- Editing in projection view updates the 2D top-down view (bidirectional sync) +- Measurement annotations show heights and distances +- Wall selection syncs between 2D and projection views +- Each projection view has independent zoom/pan + +## Notes +- Wall direction mapping: for rectangular rooms, walls map to N/S/E/W based on polygon edge order. For non-rectangular rooms, wall direction is assigned based on the dominant direction of each wall segment. +- Projection views are secondary to the 2D top-down view — they provide a different perspective but the 2D view is the primary editing surface +- Items NOT on a wall (ceiling lights, free-standing furniture) don't appear in projection views +- Elevation (height) data comes from `elevationFromFloor` field on electrical items and furniture height +- Default heights: outlets 0.3m, switches 1.2m, wall lights 2.0m + +## Review Checklist +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Phase 8 + +### Key state available from Phase 6 + +- **Projection panel** is integrated into `RoomEditorLayout` below the 2D canvas in the `canvasArea` flex column. +- **`ProjectionPanel`** supports two layout modes: tabs (one wall at a time) and grid (2x2 showing up to 4 walls). Collapsible via header click. +- **`WallProjectionView`** is a self-contained Konva `` with independent zoom (scroll) and pan (middle-click drag). It renders wall rect, floor/ceiling lines, plinth, openings, electrical, furniture, and measurements. +- **`projectionMapping.ts`** utility provides coordinate conversion (`projectionToPixel`, `projectionScale`) and data projection functions (`projectOpenings`, `projectElectricalItems`, `projectFurnitureItems`, `computePlinthSegments`). +- **Bidirectional sync**: clicking items in projection view calls `selectElement` from EditorContext, which updates `selectedIds` used by both 2D and projection views. When a wall-related item is selected in 2D, the projection panel auto-switches to that wall's tab. +- **Measurements**: `ProjectionMeasurements` renders dimension lines with arrows for wall width, wall height, opening widths/heights, window sill heights, and electrical item elevation from floor. + +### For Phase 8 (Export) + +- Each `WallProjectionView` uses its own Konva ``, so `stage.toDataURL()` can be called on each to export projection PNGs independently. +- The projection panel renders inside the `canvasArea` div, so the main 2D stage ref is separate and unaffected. +- Wall labels use `wallDirectionLabel()` from `projectionMapping.ts` which returns "North Wall", "South Wall", etc. diff --git a/plans/house-plan-maker/phase-7-3d-view.md b/plans/house-plan-maker/phase-7-3d-view.md new file mode 100644 index 0000000..a68c431 --- /dev/null +++ b/plans/house-plan-maker/phase-7-3d-view.md @@ -0,0 +1,99 @@ +# Phase 7: 3D Perspective View + +**Status:** :white_check_mark: Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Build a read-only 3D perspective view of the room using @react-three/fiber and @react-three/drei. Extrude walls from the 2D polygon, place doors/windows as openings, render furniture and electrical items as 3D representations. + +## Tasks + +- [x] Task 1: Create `Room3DView` component — @react-three/fiber Canvas with orbit controls (rotate, zoom, pan). Ambient + directional lighting. +- [x] Task 2: Implement wall extrusion — convert 2D wall polygon to 3D wall meshes. Walls are extruded rectangles with wallHeight. Interior faces rendered, exterior optional. +- [x] Task 3: Implement floor and ceiling planes — floor mesh matching room polygon shape, ceiling mesh at wallHeight. Floor with subtle material, ceiling lighter. +- [x] Task 4: Implement door openings — cut door-shaped holes in wall meshes using wall geometry splitting. Door frame mesh. +- [x] Task 5: Implement window openings — cut window-shaped holes in walls at correct elevation. Window frame and glass material (transparent, slight blue tint). +- [x] Task 6: Create 3D furniture representations — simple geometric shapes matching furniture types. Bed (box + headboard), desk (tabletop + legs), wardrobe (tall box), sofa (L-shape), table (top + legs), chair (seat + back + legs), shelf (flat box). +- [x] Task 7: Create 3D electrical representations — simple 3D indicators. Outlets/switches as small boxes on walls, ceiling lights as disc/sphere hanging from ceiling, wall lights as half-sphere on wall. +- [x] Task 8: Implement camera presets — buttons for preset camera angles: bird's eye, eye level, each corner. +- [x] Task 9: Integrate 3D view into editor page — add "3D Preview" toggle that switches between 2D and 3D views. +- [x] Task 10: Implement selection sync — clicking a furniture/electrical item in 3D view highlights it in the 2D view (via shared EditorContext selectedIds). Read-only in 3D. +- [x] Task 11: Add room label and dimension text in 3D — room name floating above, wall lengths as HTML overlay via drei's Html component. +- [x] Task 12: Render plinth (baseboard) in 3D — extrude plinth geometry along the base of each wall using room's `plinthHeight` and `plinthThickness`. Plinth is interrupted at door openings. Different material/color than walls. + +## Files to Modify/Create +- `apps/client/src/components/editor/three/Room3DView.tsx` — main 3D canvas component +- `apps/client/src/components/editor/three/WallMesh.tsx` — wall extrusion with openings +- `apps/client/src/components/editor/three/FloorCeiling.tsx` — floor and ceiling planes +- `apps/client/src/components/editor/three/DoorOpening.tsx` — door hole in wall + frame +- `apps/client/src/components/editor/three/WindowOpening.tsx` — window hole + glass +- `apps/client/src/components/editor/three/FurnitureMesh.tsx` — 3D furniture factory (renders based on type) +- `apps/client/src/components/editor/three/ElectricalMesh.tsx` — 3D electrical indicators +- `apps/client/src/components/editor/three/CameraControls.tsx` — camera presets + orbit controls +- `apps/client/src/components/editor/three/RoomLabels.tsx` — room name + dimensions in 3D +- `apps/client/src/components/editor/three/PlinthMesh.tsx` — baseboard/plinth along wall bases +- `apps/client/src/components/editor/three/utils/wallGeometry.ts` — wall splitting, coordinate conversion, geometry helpers +- `apps/client/src/components/editor/RoomEditorLayout.tsx` — added 2D/3D toggle (update) +- `apps/client/src/components/editor/room-editor-layout.module.css` — added toggle button styles (update) + +## Acceptance Criteria +- [x] 3D view renders room walls extruded to correct height +- [x] Floor and ceiling are visible +- [x] Doors appear as openings in walls with frames +- [x] Windows appear as openings with glass at correct elevation +- [x] All furniture types render as recognizable 3D shapes at correct positions and sizes +- [x] Electrical items visible on walls/ceiling +- [x] Orbit controls allow free rotation, zoom, and pan +- [x] Camera presets switch between useful viewing angles +- [x] Clicking items in 3D highlights them in 2D editor +- [x] 3D view updates when room data changes (reactive) + +## Notes +- 3D view is READ-ONLY — no editing in 3D. All changes happen in the 2D editor or projection views. +- Wall openings are implemented by splitting wall geometry into sections around openings (simpler and more performant than CSG). +- Furniture 3D models are intentionally simple — box-based geometry, not detailed meshes. The goal is spatial understanding, not photorealism. +- Uses drei helpers: `OrbitControls`, `PerspectiveCamera`, `Html`, `ContactShadows`. +- Three.js uses Y-up coordinate system — 2D (x, y) -> 3D (x, 0, y) for floor plane; Y axis is height. +- Performance: rooms typically have <100 objects — no LOD or instancing needed. +- Room3DView is lazy-loaded to avoid pulling Three.js into the initial bundle. + +## Review Checklist +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects +- [x] Build passes +- [x] Tests pass (new + existing) + +## Handoff to Next Phase + +### Phase 8 Handoff Notes + +**What was built:** +- Complete 3D perspective view system with 11 new files in `apps/client/src/components/editor/three/` +- 2D/3D toggle integrated into `RoomEditorLayout` via lazy-loaded `Room3DView` component +- Wall geometry splitting algorithm in `utils/wallGeometry.ts` that creates solid wall segments around door/window openings +- Coordinate conversion: 2D editor (X-right, Y-down) to Three.js (X-right, Y-up, Z-forward) via `(x, 0, y)` mapping + +**Key architecture decisions:** +- Room3DView reads all data from `EditorContext` (same state as 2D editor) — fully reactive +- Selection sync: clicking items in 3D dispatches `SET_SELECTED` to EditorContext, same selection state used by both views +- Lazy loading via `React.lazy()` so Three.js bundle (~500KB) only loads when 3D view is opened +- No CSG — walls are split into rectangular segments (above/below/around openings) using `splitWallAroundOpenings()` + +**Dependencies added:** +- `@react-three/fiber` ^9.5.0 +- `@react-three/drei` ^10.7.7 +- `three` ^0.183.2 +- `@types/three` ^0.183.1 (devDependency) + +**Integration points:** +- `RoomEditorLayout.tsx` — 2D/3D toggle buttons added above the canvas area +- When 3D is active, the 2D canvas, CableLengthStatus, and ProjectionPanel are hidden (replaced by Room3DView) +- Camera presets UI is rendered as absolute-positioned HTML overlay inside the 3D canvas container + +**Concerns / known limitations:** +- Camera preset transitions are instant (not animated) — smooth GSAP/tween animation could be added later +- `OrbitControls` ref is typed as `any` due to drei typing limitations +- The `Environment` preset from drei is not used (ambient + directional lights used instead) to avoid loading HDR assets +- Wall thickness rendering uses `boxGeometry` aligned along wall centerline; for rooms with very thick walls or sharp angles, there may be small gaps at corners diff --git a/plans/house-plan-maker/phase-8-export-polish.md b/plans/house-plan-maker/phase-8-export-polish.md new file mode 100644 index 0000000..b1eb898 --- /dev/null +++ b/plans/house-plan-maker/phase-8-export-polish.md @@ -0,0 +1,66 @@ +# Phase 8: Export Pipeline + Polish + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Implement PDF/PNG export for all views, add room templates, and perform final integration polish. This is the final Big Bang phase — everything must compile, pass tests, and work end-to-end. + +## Tasks + +- [ ] Task 1: Implement PNG export for 2D top-down view — export the Konva stage to PNG image via `stage.toDataURL()`. Options: include/exclude grid, include/exclude measurements, scale factor. +- [ ] Task 2: Implement PNG export for each wall projection view — same approach, each projection exports independently. +- [ ] Task 3: Implement PNG export for 3D view — capture Three.js renderer output via `renderer.domElement.toDataURL()`. +- [ ] Task 4: Implement PDF export for single room — compose a PDF page with: room name/dimensions header, 2D top-down view, 4 projection views, optional 3D preview. Use jsPDF with canvas images. +- [ ] Task 5: Implement PDF export for full apartment — multi-page PDF. Cover page with apartment info, then one section per room (same layout as single room export). Table of contents. +- [ ] Task 6: Add export dialog UI — modal with export options: format (PNG/PDF), scope (current view / room / apartment), layers to include, scale, paper size (A4/A3/Letter). +- [ ] Task 7: Create room templates — preset room configurations: bedroom (bed, nightstands, wardrobe), kitchen (counters, table, chairs), bathroom (placeholder shapes), living room (sofa, table, TV stand), office (desk, chair, bookcase). Templates provide initial furniture layout. +- [ ] Task 8: Implement "Apply Template" UI — when creating a room, option to start from a template. Template applies furniture items and suggested room dimensions. +- [ ] Task 9: Implement copy room layout — duplicate a room's element layout (electrical + furniture) to another room in the same or different apartment. +- [ ] Task 10: Final integration — ensure all phases work together end-to-end. Fix any Big Bang integration issues: type mismatches, missing imports, broken references between phases. +- [ ] Task 11: Write comprehensive tests — unit tests for geometry utils, API endpoint tests, component render tests for key UI components. Target 80%+ coverage on critical paths. +- [ ] Task 12: Ensure full build passes — `turbo build` succeeds for all packages. Fix all TypeScript errors. Fix all ESLint warnings. +- [ ] Task 13: Ensure full test suite passes — `turbo test` succeeds. +- [ ] Task 14: Add loading performance optimization — lazy load the 3D view (React.lazy), lazy load export libraries (jsPDF, html2canvas). + +## Files to Modify/Create +- `apps/client/src/components/export/ExportDialog.tsx` — export options modal +- `apps/client/src/components/export/exportUtils.ts` — canvas-to-image helpers +- `apps/client/src/components/export/pdfExport.ts` — PDF generation logic +- `apps/client/src/components/export/pngExport.ts` — PNG export logic +- `apps/client/src/components/templates/roomTemplates.ts` — template definitions +- `apps/client/src/components/templates/TemplateSelector.tsx` — template picker UI +- `apps/client/src/components/rooms/CopyRoomDialog.tsx` — copy layout dialog +- `apps/client/src/api/client.ts` — add copy room API call (update) +- `apps/server/src/routes/rooms.ts` — add `POST /api/rooms/:id/copy` endpoint (update) +- Various test files across packages +- Multiple existing files may need fixes for Big Bang integration + +## Acceptance Criteria +- PNG export works for 2D view, projection views, and 3D view +- PDF export generates clean, printable documents for single rooms and full apartments +- Room templates provide sensible starting layouts for common room types +- Copy room layout duplicates elements to another room +- Full build passes: `turbo build` with 0 errors +- Full test suite passes: `turbo test` with 0 failures +- All TypeScript strict mode errors resolved +- Application runs end-to-end: create apartment → add rooms → edit with all tools → export PDF + +## Notes +- This is the Big Bang final phase — ALL accumulated integration issues must be resolved here +- PDF should be print-quality — use appropriate DPI (300 for print) +- Export should work offline (client-side only, no server dependency) +- Templates are just convenience — users can always start from scratch and build manually +- Test priority: geometry utils > API endpoints > editor interactions > export +- Lazy loading reduces initial bundle size — 3D and export are not needed on first load + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes (REQUIRED — final Big Bang phase) +- [ ] Tests pass (REQUIRED — final Big Bang phase) + +## Handoff to Next Phase + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2d470ce --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true + }, + "references": [ + { "path": "apps/client" }, + { "path": "apps/server" }, + { "path": "packages/shared" } + ] +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..aaa97e5 --- /dev/null +++ b/turbo.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://turborepo.dev/schema.json", + "globalDependencies": ["tsconfig.json"], + "globalEnv": ["NODE_ENV"], + "ui": "tui", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "test": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] + }, + "lint": { + "outputs": [] + } + } +}