diff --git a/apps/editor/public/items/office-chair/floor-plan.svg b/apps/editor/public/items/office-chair/floor-plan.svg new file mode 100644 index 000000000..4529c01c8 --- /dev/null +++ b/apps/editor/public/items/office-chair/floor-plan.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/editor/public/items/sofa/floor-plan.svg b/apps/editor/public/items/sofa/floor-plan.svg new file mode 100644 index 000000000..6b3965773 --- /dev/null +++ b/apps/editor/public/items/sofa/floor-plan.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/bun.lock b/bun.lock index fc2f7c2fe..c0e5fbbea 100644 --- a/bun.lock +++ b/bun.lock @@ -56,8 +56,6 @@ "idb-keyval": "^6.2.2", "mitt": "^3.0.1", "nanoid": "^5.1.6", - "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.8", "zod": "^4.3.5", "zundo": "^2.3.0", "zustand": "^5", @@ -79,6 +77,9 @@ "name": "@pascal-app/editor", "version": "0.6.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -193,6 +194,8 @@ "version": "0.6.0", "dependencies": { "polygon-clipping": "^0.15.7", + "three-bvh-csg": "^0.0.18", + "three-mesh-bvh": "^0.9.8", "zustand": "^5", }, "devDependencies": { @@ -278,6 +281,14 @@ "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], diff --git a/packages/core/package.json b/packages/core/package.json index 131288237..8cffdb40b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -67,8 +67,6 @@ "idb-keyval": "^6.2.2", "mitt": "^3.0.1", "nanoid": "^5.1.6", - "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.8", "zod": "^4.3.5", "zundo": "^2.3.0", "zustand": "^5" diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 6cb4215e7..7530b30ec 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -6,6 +6,7 @@ import type { CeilingNode, DoorNode, FenceNode, + GuideNode, ItemNode, LevelNode, RoofNode, @@ -130,6 +131,12 @@ type ToolEvents = { 'tool:cancel': undefined } +type GuideEvents = { + 'guide:set-reference-scale': { guideId: GuideNode['id'] } + 'guide:cancel-reference-scale': undefined + 'guide:deleted': { guideId: GuideNode['id'] } +} + type PresetEvents = { 'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } @@ -170,6 +177,7 @@ type EditorEvents = GridEvents & NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents & + GuideEvents & PresetEvents & ThumbnailEvents & SnapshotEvents & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 254d1fc2c..59e84f2bc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,7 @@ export { } from './hooks/spatial-grid/spatial-grid-sync' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' +export { getRenderableSlabPolygon } from './lib/slab-polygon' export { detectSpacesForLevel, initSpaceDetectionSync, @@ -47,32 +48,25 @@ export { LIBRARY_MATERIAL_REF_PREFIX, MATERIAL_CATALOG, MATERIAL_CATEGORIES, - type MaterialCategory, type MaterialCatalogItem, + type MaterialCategory, toLibraryMaterialRef, } from './material-library' -export { baseMaterial, glassMaterial } from './materials' export * from './schema' -export { - type ControlValue, - type ItemInteractiveState, - useInteractive, -} from './store/use-interactive' export { getSceneHistoryPauseDepth, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, } from './store/history-control' +export { + type ControlValue, + type ItemInteractiveState, + useInteractive, +} from './store/use-interactive' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' -export { CeilingSystem } from './systems/ceiling/ceiling-system' -export { DoorSystem } from './systems/door/door-system' -export { FenceSystem } from './systems/fence/fence-system' -export { ItemSystem } from './systems/item/item-system' -export { RoofSystem } from './systems/roof/roof-system' -export { SlabSystem } from './systems/slab/slab-system' -export { StairSystem } from './systems/stair/stair-system' +export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { getClampedWallCurveOffset, getMaxWallCurveOffset, @@ -94,14 +88,13 @@ export { } from './systems/wall/wall-footprint' export { calculateLevelMiters, + getAdjacentWallIds, getWallMiterBoundaryPoints, type Point2D, pointToKey, type WallMiterBoundaryPoints, type WallMiterData, } from './systems/wall/wall-mitering' -export { WallSystem } from './systems/wall/wall-system' -export { WindowSystem } from './systems/window/window-system' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts new file mode 100644 index 000000000..4dd2f4bff --- /dev/null +++ b/packages/core/src/lib/slab-polygon.ts @@ -0,0 +1,67 @@ +import type { SlabNode } from '../schema' +import { insetPolygonFromCentroid, simplifyClosedPolygon } from './polygon-geometry' + +/** Half of default wall thickness — used to extend slab geometry under walls */ +const SLAB_OUTSET = 0.05 +const AUTO_SLAB_INSET = 0.02 +const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08 + +export function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> { + return slabNode.autoFromWalls + ? simplifyClosedPolygon( + insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET), + AUTO_SLAB_SIMPLIFY_TOLERANCE, + ) + : outsetPolygon(slabNode.polygon, SLAB_OUTSET) +} + +/** + * Expand a polygon outward by a uniform distance. + * Offsets each edge outward then intersects consecutive offset edges. + */ +function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<[number, number]> { + const n = polygon.length + if (n < 3) return polygon + + // Determine winding via signed area + let area2 = 0 + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + area2 += polygon[i]![0] * polygon[j]![1] - polygon[j]![0] * polygon[i]![1] + } + const s = area2 >= 0 ? 1 : -1 + + // Offset each edge outward by amount + const offEdges: Array<[number, number, number, number]> = [] + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + const dx = polygon[j]![0] - polygon[i]![0] + const dz = polygon[j]![1] - polygon[i]![1] + const len = Math.sqrt(dx * dx + dz * dz) + if (len < 1e-9) { + offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz]) + continue + } + const nx = ((s * dz) / len) * amount + const nz = ((s * -dx) / len) * amount + offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz]) + } + + // Intersect consecutive offset edges to get new vertices + const result: Array<[number, number]> = [] + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + const [ax, az, adx, adz] = offEdges[i]! + const [bx, bz, bdx, bdz] = offEdges[j]! + const denom = adx * bdz - adz * bdx + if (Math.abs(denom) < 1e-9) { + // Parallel edges — use offset endpoint + result.push([ax + adx, az + adz]) + } else { + const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom + result.push([ax + t * adx, az + t * adz]) + } + } + + return result +} diff --git a/packages/core/src/materials.ts b/packages/core/src/materials.ts deleted file mode 100644 index 089d26778..000000000 --- a/packages/core/src/materials.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' - -/** - * Shared base material for structural elements: walls, frames, slabs, roof. - */ -export const baseMaterial = new MeshStandardNodeMaterial({ - color: '#f2f0ed', - roughness: 0.5, - metalness: 0, -}) - -/** - * Shared glass material for windows, glazed door panels, and glass items. - */ -export const glassMaterial = new MeshStandardNodeMaterial({ - name: 'glass', - color: 'lightblue', - roughness: 0.05, - metalness: 0.1, - transparent: true, - opacity: 0.35, - side: DoubleSide, - depthWrite: false, -}) diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index a86f1a716..cfb464c56 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -28,7 +28,7 @@ export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { DoorNode, DoorSegment } from './nodes/door' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' -export { GuideNode } from './nodes/guide' +export { GuideNode, GuideScaleReference } from './nodes/guide' export type { AnimationEffect, Asset, @@ -43,13 +43,14 @@ export type { } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item' export { LevelNode } from './nodes/level' -export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' +export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' +export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { SpawnNode } from './nodes/spawn' export { getEffectiveStairSurfaceMaterial, @@ -59,7 +60,6 @@ export { StairTopLandingMode, StairType, } from './nodes/stair' -export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 519ac6a40..8940270a4 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -32,6 +32,15 @@ export const DoorNode = BaseNode.extend({ width: z.number().default(0.9), height: z.number().default(2.1), + // Opening mode + openingKind: z.enum(['door', 'opening']).default('door'), + openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), + openingRadiusMode: z.enum(['all', 'individual']).default('all'), + openingTopRadii: z.tuple([z.number(), z.number()]).default([0.15, 0.15]), + cornerRadius: z.number().min(0).default(0.15), + archHeight: z.number().min(0).default(0.45), + openingRevealRadius: z.number().min(0).default(0.025), + // Frame frameThickness: z.number().default(0.05), frameDepth: z.number().default(0.07), @@ -41,6 +50,11 @@ export const DoorNode = BaseNode.extend({ // Swing hingesSide: z.enum(['left', 'right']).default('left'), swingDirection: z.enum(['inward', 'outward']).default('inward'), + swingAngle: z + .number() + .min(0) + .max(Math.PI / 2) + .default(0), // Leaf segments — stacked top to bottom, each with its own column split segments: z.array(DoorSegment).default([ @@ -76,9 +90,10 @@ export const DoorNode = BaseNode.extend({ panicBarHeight: z.number().default(1.0), }).describe(dedent`Door node - a parametric door placed on a wall - position: center of the door in wall-local coordinate system (Y = height/2, always at floor) + - openingKind/openingShape: hinged door or frameless wall opening shape - segments: rows stacked top to bottom, each defining its own columnRatios - type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed - - hingesSide/swingDirection: which way the door opens + - hingesSide/swingDirection/swingAngle: which way the door opens and how far it is currently open - doorCloser/panicBar: commercial and emergency hardware options `) diff --git a/packages/core/src/schema/nodes/guide.ts b/packages/core/src/schema/nodes/guide.ts index 359887e93..8dafffb20 100644 --- a/packages/core/src/schema/nodes/guide.ts +++ b/packages/core/src/schema/nodes/guide.ts @@ -2,6 +2,15 @@ import { z } from 'zod' import { AssetUrl } from '../asset-url' import { BaseNode, nodeType, objectId } from '../base' +export const GuideScaleReference = z.object({ + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + realLengthMeters: z.number().positive(), + measuredLengthUnits: z.number().positive(), + metersPerUnit: z.number().positive(), + label: z.string(), +}) + export const GuideNode = BaseNode.extend({ id: objectId('guide'), type: nodeType('guide'), @@ -10,6 +19,8 @@ export const GuideNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), scale: z.number().default(1), opacity: z.number().min(0).max(100).default(50), + scaleReference: GuideScaleReference.nullable().default(null), }) +export type GuideScaleReference = z.infer export type GuideNode = z.infer diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 7e28b71f6..a497175a8 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -19,6 +19,17 @@ export const WindowNode = BaseNode.extend({ width: z.number().default(1.5), height: z.number().default(1.5), + // Opening mode - when set to "opening", the window is only a shaped cutout + openingKind: z.enum(['window', 'opening']).default('window'), + openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), + openingRadiusMode: z.enum(['all', 'individual']).default('all'), + openingCornerRadii: z + .tuple([z.number(), z.number(), z.number(), z.number()]) + .default([0.15, 0.15, 0.15, 0.15]), + cornerRadius: z.number().default(0.15), + archHeight: z.number().default(0.35), + openingRevealRadius: z.number().default(0.025), + // Frame frameThickness: z.number().default(0.05), frameDepth: z.number().default(0.07), diff --git a/packages/editor/package.json b/packages/editor/package.json index dbe2e5d04..24ee4e8bb 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -21,6 +21,9 @@ "three": "^0.184" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index 1fdc5c816..d08f3e848 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -15,6 +15,7 @@ export type FloorplanActionMenuEntry = { position: SvgPoint | null onDelete: FloorplanActionMenuHandler onMove: FloorplanActionMenuHandler + onAddHole?: FloorplanActionMenuHandler onDuplicate?: FloorplanActionMenuHandler } @@ -76,6 +77,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ }} > draftFill: string draftStroke: string + polygonDraftStroke?: string + polygonDraftStrokeWidth?: string anchorFill: string + unitsPerPixel: number } export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({ @@ -30,8 +33,15 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({ draftAnchorPoints, draftFill, draftStroke, + polygonDraftStroke, + polygonDraftStrokeWidth = '0.08', anchorFill, + unitsPerPixel, }: FloorplanDraftLayerProps) { + const primaryAnchorRadius = 6 * unitsPerPixel + const secondaryAnchorRadius = 5 * unitsPerPixel + const activePolygonDraftStroke = polygonDraftStroke ?? draftStroke + return ( <> {draftPolygonPoints && ( @@ -69,21 +79,21 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({ )} {polygonDraftClosingSegment && ( ))} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx index 447cd7342..9885d6fc1 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx @@ -1,7 +1,7 @@ 'use client' -import { memo } from 'react' import type { Point2D, RoofNode, RoofSegmentNode } from '@pascal-app/core' +import { memo } from 'react' import { toSvgX, toSvgY } from '../svg-paths' type FloorplanLineSegment = { @@ -20,14 +20,27 @@ type FloorplanRoofEntry = { segments: FloorplanRoofSegmentEntry[] } +type FloorplanRoofPalette = { + roofFill: string + roofActiveFill: string + roofSelectedFill: string + roofStroke: string + roofActiveStroke: string + roofSelectedStroke: string + roofRidgeStroke: string + roofSelectedRidgeStroke: string +} + type FloorplanRoofLayerProps = { highlightedIdSet: ReadonlySet + palette: FloorplanRoofPalette roofEntries: FloorplanRoofEntry[] selectedIdSet: ReadonlySet } export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({ highlightedIdSet, + palette, roofEntries, selectedIdSet, }: FloorplanRoofLayerProps) { @@ -59,18 +72,18 @@ export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({ = Math.PI * 2) { return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001) @@ -74,6 +83,25 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { return baseSweepAngle } +function getFloorplanStairStepCount(stair: StairNode, minimum: number) { + return Math.max(minimum, Math.round(stair.stepCount ?? 10)) +} + +function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { + if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') { + return 0 + } + + const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9) + const width = Math.max(stair.width ?? 1, 0.4) + const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8)) + + return ( + Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) * + Math.sign(sweepAngle || 1) + ) +} + export const FloorplanStairLayer = memo(function FloorplanStairLayer({ canFocusStairs, canSelectStairs, @@ -108,8 +136,10 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ stairSelected || stairHighlighted || segmentSelected || segmentHighlighted const stairType = stair.stairType ?? 'straight' const normalizedSweepAngle = getNormalizedFloorplanStairSweepAngle(stair) - const sectorStartAngle = stair.rotation - normalizedSweepAngle / 2 + const sectorStartAngle = -stair.rotation - normalizedSweepAngle / 2 const sectorEndAngle = sectorStartAngle + normalizedSweepAngle + const spiralLandingSweep = getFloorplanSpiralLandingSweep(stair, normalizedSweepAngle) + const visualSectorEndAngle = sectorEndAngle + spiralLandingSweep const stairCenter = { x: stair.position[0], y: stair.position[2], @@ -124,37 +154,37 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ ? palette.deleteStroke : isSelectionActive ? '#2563eb' - : 'rgba(31, 41, 55, 0.9)' + : palette.stairStroke const curvedAccent = isDeleteHovered ? palette.deleteStroke : isSelectionActive ? '#1d4ed8' - : 'rgba(23, 23, 23, 0.96)' + : palette.stairAccent const curvedFill = isDeleteHovered ? palette.deleteFill : isSelectionActive - ? 'rgba(59, 130, 246, 0.16)' - : '#ffffff' + ? palette.stairSelectedFill + : palette.stairFill const straightAccent = isDeleteHovered ? palette.deleteStroke : isSelectionActive ? '#1d4ed8' - : 'rgba(23, 23, 23, 0.96)' + : palette.stairAccent const straightStroke = isDeleteHovered ? palette.deleteStroke : isSelectionActive ? '#1d4ed8' - : 'rgba(23, 23, 23, 0.88)' + : palette.stairStroke const straightTread = isDeleteHovered ? palette.deleteStroke : isSelectionActive - ? 'rgba(37, 99, 235, 0.78)' - : 'rgba(38, 38, 38, 0.62)' + ? palette.stairSelectedTread + : palette.stairTread const straightFill = isDeleteHovered ? palette.deleteFill : isSelectionActive - ? 'rgba(59, 130, 246, 0.08)' - : 'rgba(255, 255, 255, 0.02)' + ? palette.stairSelectedFill + : palette.stairFill const curvedOuterLineWidth = isSelectionActive ? '2' : '1.4' const curvedInnerLineWidth = isSelectionActive ? '1.7' : '1.2' const stairSymbol = @@ -166,13 +196,18 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ innerRadius, outerRadius, sectorStartAngle, - sectorEndAngle, + visualSectorEndAngle, )} fill={curvedFill} pointerEvents="none" /> - {Array.from({ length: Math.max(6, stair.stepCount) }, (_, index) => { - const stepCount = Math.max(6, stair.stepCount) + {Array.from({ length: getFloorplanStairStepCount(stair, 6) + 1 }, (_, index) => { + const stepCount = getFloorplanStairStepCount(stair, 6) const stepSweep = normalizedSweepAngle / stepCount const angle = sectorStartAngle + stepSweep * index const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) @@ -199,9 +239,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ = dashedFromIndex ? '0.1 0.08' : undefined} - strokeWidth={index === stepCount - 1 ? '1.8' : '1.15'} + strokeWidth={index === stepCount ? '1.8' : '1.15'} vectorEffect="non-scaling-stroke" x1={toSvgX(innerPoint.x)} x2={toSvgX(outerPoint.x)} @@ -213,7 +253,7 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ {(() => { - const directionAngle = sectorStartAngle + normalizedSweepAngle * 0.86 + const directionAngle = + visualSectorEndAngle - + (normalizedSweepAngle / getFloorplanStairStepCount(stair, 6)) * 0.8 const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, directionAngle) const tangentAngle = directionAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2) @@ -269,8 +311,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ strokeWidth={curvedInnerLineWidth} vectorEffect="non-scaling-stroke" /> - {Array.from({ length: Math.max(4, stair.stepCount) + 1 }, (_, index) => { - const stepCount = Math.max(4, stair.stepCount) + {Array.from({ length: getFloorplanStairStepCount(stair, 4) + 1 }, (_, index) => { + const stepCount = getFloorplanStairStepCount(stair, 4) const stepSweep = normalizedSweepAngle / stepCount const angle = sectorStartAngle + stepSweep * index const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) @@ -294,8 +336,10 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ d={buildSvgArcPath( stairCenter, centerlineRadius, - sectorStartAngle + (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55, - sectorEndAngle - (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55, + sectorStartAngle + + (normalizedSweepAngle / getFloorplanStairStepCount(stair, 4)) * 0.55, + sectorEndAngle - + (normalizedSweepAngle / getFloorplanStairStepCount(stair, 4)) * 0.55, )} fill="none" pointerEvents="none" @@ -305,7 +349,7 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ vectorEffect="non-scaling-stroke" /> {(() => { - const stepCount = Math.max(4, stair.stepCount) + const stepCount = getFloorplanStairStepCount(stair, 4) const stepSweep = normalizedSweepAngle / stepCount const arrowAngle = sectorEndAngle - stepSweep * 0.8 const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index b6365ce90..ee39342ec 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,15 +1,13 @@ 'use client' import '../../three-types' -import { KeyboardControls } from '@react-three/drei' -import { sceneRegistry, useScene } from '@pascal-app/core' +import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Euler, Vector3 } from 'three' +import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three' import useEditor from '../../store/use-editor' -import BVHEcctrl from './first-person/bvh-ecctrl' -import type { BVHEcctrlApi } from './first-person/bvh-ecctrl' import { buildFirstPersonColliderWorldFromRegistry, deriveFirstPersonSpawn, @@ -17,10 +15,15 @@ import { type FirstPersonColliderWorld, type FirstPersonSpawn, } from './first-person/build-collider-world' +import type { BVHEcctrlApi } from './first-person/bvh-ecctrl' +import BVHEcctrl from './first-person/bvh-ecctrl' const CAMERA_EYE_OFFSET = 0.45 const LOOK_SENSITIVITY = 0.002 const CONTROLLER_CENTER_FROM_EYE = 0.85 +const DOOR_INTERACTION_DISTANCE = 2.5 +const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 +const DOOR_LEAF_INTERACTION_DEPTH = 0.08 const keyboardMap = [ { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, @@ -32,13 +35,21 @@ const keyboardMap = [ const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) const cameraEuler = new Euler(0, 0, 0, 'YXZ') +const centerScreenPoint = new Vector2(0, 0) +const doorInteractionRaycaster = new Raycaster() +const doorLeafBox = new Box3() +const doorLeafInverseMatrix = new Matrix4() +const doorLeafLocalHit = new Vector3() +const doorLeafLocalRay = new Ray() +const doorLeafMatrix = new Matrix4() +const doorLeafWorldHit = new Vector3() const spawnWorldPosition = new Vector3() const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const resolvePlacedSpawnNode = ( nodes: ReturnType['nodes'], _levelId: string | null, - ) => { +) => { const candidates = Object.values(nodes).filter((node) => node.type === 'spawn') if (candidates.length === 0) return null @@ -52,7 +63,101 @@ export const FirstPersonControls = () => { const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) + const interactableDoorIdRef = useRef(null) + const worldRef = useRef(null) const [world, setWorld] = useState(null) + const [controllerStart, setControllerStart] = useState<{ + position: [number, number, number] + yaw: number + } | null>(null) + + const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => { + worldRef.current?.dispose() + worldRef.current = nextWorld + setWorld(nextWorld) + }, []) + + const rebuildColliderWorld = useCallback(() => { + replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry()) + }, [replaceColliderWorld]) + + const resolveInteractableDoorId = useCallback((): AnyNodeId | null => { + const nodes = useScene.getState().nodes + camera.updateMatrixWorld(true) + doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera) + + let closestDoorId: AnyNodeId | null = null + let closestDistance = DOOR_INTERACTION_DISTANCE + + for (const doorId of sceneRegistry.byType.door) { + const node = nodes[doorId as AnyNodeId] + if (node?.type !== 'door') continue + if (node.openingKind === 'opening') continue + if (node.segments.every((segment) => segment.type === 'empty')) continue + + const object = sceneRegistry.nodes.get(doorId) + if (!object) continue + + object.updateWorldMatrix(true, true) + + const placementHit = doorInteractionRaycaster + .intersectObject(object, true) + .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE) + if (placementHit && placementHit.distance < closestDistance) { + closestDoorId = doorId as AnyNodeId + closestDistance = placementHit.distance + } + + const leafW = node.width - 2 * node.frameThickness + const leafH = node.height - node.frameThickness + if (leafW <= 0 || leafH <= 0) continue + + const leafCenterY = -node.frameThickness / 2 + const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 + const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 + const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 + const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, node.swingAngle ?? 0)) + const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign + + doorLeafMatrix + .copy(object.matrixWorld) + .multiply(new Matrix4().makeTranslation(hingeX, 0, 0)) + .multiply(new Matrix4().makeRotationY(leafSwingRotation)) + .multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0)) + doorLeafInverseMatrix.copy(doorLeafMatrix).invert() + doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2) + doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2) + doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix) + + const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit) + if (!localHit) continue + + doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix) + const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin) + + if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) { + closestDoorId = doorId as AnyNodeId + closestDistance = hitDistance + } + } + + return closestDoorId + }, [camera]) + + const toggleInteractableDoor = useCallback(() => { + const doorId = interactableDoorIdRef.current ?? resolveInteractableDoorId() + if (!doorId) return + + const node = useScene.getState().nodes[doorId] + if (node?.type !== 'door' || node.openingKind === 'opening') return + + const currentSwingAngle = node.swingAngle ?? 0 + useScene.getState().updateNode(doorId, { + swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + }) + + requestAnimationFrame(rebuildColliderWorld) + }, [rebuildColliderWorld, resolveInteractableDoorId]) const placedSpawn = useMemo(() => { if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null @@ -84,25 +189,28 @@ export const FirstPersonControls = () => { }, [placedSpawnNode]) useEffect(() => { - const nextWorld = buildFirstPersonColliderWorldFromRegistry() - if (!nextWorld) { - setWorld(null) - return - } - - setWorld(nextWorld) + rebuildColliderWorld() return () => { - nextWorld.dispose() + worldRef.current?.dispose() + worldRef.current = null setWorld(null) } - }, [camera]) + }, [rebuildColliderWorld]) useEffect(() => { if (!world) return - yawRef.current = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw + if (controllerStart) return + + const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world) + const [x, y, z] = spawn.position + yawRef.current = spawn.yaw pitchRef.current = 0 - }, [camera, placedSpawn, world]) + setControllerStart({ + position: [x, y - CONTROLLER_CENTER_FROM_EYE, z], + yaw: spawn.yaw, + }) + }, [camera, controllerStart, placedSpawn, world]) useEffect(() => { const canvas = gl.domElement @@ -152,6 +260,10 @@ export const FirstPersonControls = () => { document.exitPointerLock() } useEditor.getState().setFirstPersonMode(false) + } else if (event.code === 'KeyE') { + event.preventDefault() + event.stopPropagation() + toggleInteractableDoor() } } @@ -159,7 +271,7 @@ export const FirstPersonControls = () => { return () => { document.removeEventListener('keydown', handleKeyDown, true) } - }, [gl]) + }, [gl, toggleInteractableDoor]) useFrame((_, delta) => { if (!controllerRef.current?.group) return @@ -170,18 +282,21 @@ export const FirstPersonControls = () => { cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') camera.quaternion.setFromEuler(cameraEuler) camera.updateMatrixWorld(true) - }) - const controllerPosition = useMemo(() => { - if (!world) return null - const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position - return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const - }, [camera, placedSpawn, world]) + const nextInteractableDoorId = resolveInteractableDoorId() + if (interactableDoorIdRef.current !== nextInteractableDoorId) { + interactableDoorIdRef.current = nextInteractableDoorId + useViewer.getState().setHoveredId(nextInteractableDoorId) + } + }) - const spawnYaw = useMemo(() => { - if (!world) return 0 - return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw - }, [camera, placedSpawn, world]) + useEffect(() => { + return () => { + if (useViewer.getState().hoveredId === interactableDoorIdRef.current) { + useViewer.getState().setHoveredId(null) + } + } + }, []) if (!world) { return null @@ -189,11 +304,11 @@ export const FirstPersonControls = () => { return ( <> - {controllerPosition && ( + {controllerStart && ( { maxRunSpeed={5.5} maxSlope={1.2} maxWalkSpeed={4} - position={controllerPosition} + position={controllerStart.position} acceleration={26} airDragFactor={0.3} deceleration={30} @@ -293,7 +408,9 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
- Click to look around + + Click to look around +
)} diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index d6a6e82ea..c373d6daa 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -1,11 +1,7 @@ -import { sceneRegistry, useScene } from '@pascal-app/core' -import { - acceleratedRaycast, - computeBoundsTree, - disposeBoundsTree, -} from 'three-mesh-bvh' -import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core' import * as THREE from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' const COLLIDER_NODE_TYPES = [ 'wall', @@ -16,6 +12,7 @@ const COLLIDER_NODE_TYPES = [ 'roof', 'roof-segment', 'door', + 'window', 'item', ] as const @@ -25,6 +22,7 @@ const DOWN = new THREE.Vector3(0, -1, 0) const UP = new THREE.Vector3(0, 1, 0) const SPAWN_EYE_HEIGHT = 1.65 const RAYCAST_CLEARANCE = 25 +const DOOR_LEAF_COLLIDER_DEPTH = 0.06 export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT @@ -45,12 +43,18 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh { return 'isMesh' in object && (object as THREE.Mesh).isMesh } +function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) { + return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible +} + function cloneWorldGeometry(mesh: THREE.Mesh) { const sourceGeometry = mesh.geometry const position = sourceGeometry.getAttribute('position') if (!position || position.count < 3) return null - const workingGeometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() + const workingGeometry = sourceGeometry.index + ? sourceGeometry.toNonIndexed() + : sourceGeometry.clone() const cleanGeometry = new THREE.BufferGeometry() cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) @@ -74,16 +78,59 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { } function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) { + if (type === 'window') { + const node = useScene.getState().nodes[nodeId as AnyNodeId] + return node?.type === 'window' && node.openingKind === 'opening' + } + if (type !== 'door') return false - const node = useScene.getState().nodes[nodeId] + const node = useScene.getState().nodes[nodeId as AnyNodeId] if (!node || node.type !== 'door') return false + if (node.openingKind === 'opening') return true + if (!node.segments.length) return true return node.segments.every((segment) => segment.type === 'empty') } +function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { + const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty') + if (!hasLeafContent) return null + + const leafW = node.width - 2 * node.frameThickness + const leafH = node.height - node.frameThickness + if (leafW <= 0 || leafH <= 0) return null + + const leafCenterY = -node.frameThickness / 2 + const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 + const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 + const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 + const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0)) + const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign + + root.updateWorldMatrix(true, false) + + const sourceGeometry = new THREE.BoxGeometry( + leafW, + leafH, + DOOR_LEAF_COLLIDER_DEPTH, + ).toNonIndexed() + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) + geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) + sourceGeometry.dispose() + const matrix = root.matrixWorld + .clone() + .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0)) + .multiply(new THREE.Matrix4().makeRotationY(leafSwingRotation)) + .multiply(new THREE.Matrix4().makeTranslation(-hingeX, leafCenterY, 0)) + + geometry.applyMatrix4(matrix) + return geometry +} + function buildRegisteredNodeTypeLookup() { const nodeTypes = new Map() @@ -109,7 +156,12 @@ function collectColliderGeometriesFromNode( if (visitedMeshes.has(object)) return visitedMeshes.add(object) - if (isMesh(object) && object.visible && !SKIPPED_MESH_NAMES.has(object.name)) { + if ( + isMesh(object) && + object.visible && + isColliderMaterialVisible(object.material) && + !SKIPPED_MESH_NAMES.has(object.name) + ) { const geometry = cloneWorldGeometry(object) if (geometry) { geometries.push(geometry) @@ -151,6 +203,17 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider const root = sceneRegistry.nodes.get(nodeId) if (!root) continue + if (type === 'door') { + const node = useScene.getState().nodes[nodeId as AnyNodeId] + if (node?.type !== 'door') continue + + const doorGeometry = createDoorLeafColliderGeometry(root, node) + if (doorGeometry) { + geometries.push(doorGeometry) + } + continue + } + root.updateMatrixWorld(true) geometries.push( ...collectColliderGeometriesFromNode( @@ -169,7 +232,9 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider } const mergedGeometry = mergeGeometries(geometries, false) - geometries.forEach((geometry) => geometry.dispose()) + geometries.forEach((geometry) => { + geometry.dispose() + }) if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) { mergedGeometry?.dispose() @@ -234,7 +299,8 @@ export function deriveFirstPersonSpawn( } for (const [x, z] of candidates) { - const topY = Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE + const topY = + Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE raycaster.set(new THREE.Vector3(x, topY, z), DOWN) const intersections = raycaster.intersectObject(world.mesh, false) const hit = intersections.find((intersection) => { @@ -252,11 +318,7 @@ export function deriveFirstPersonSpawn( } return { - position: [ - camera.position.x, - Math.max(camera.position.y, SPAWN_EYE_HEIGHT), - camera.position.z, - ], + position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z], yaw, } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 4843da830..bf3b5f9cd 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -2,6 +2,7 @@ import { Icon } from '@iconify/react' import { + type AnyNode, type AnyNodeId, type BuildingNode, type CeilingNode, @@ -11,6 +12,7 @@ import { type FenceNode, type GridEvent, type GuideNode, + getRenderableSlabPolygon, getWallChordFrame, getWallCurveFrameAt, getWallCurveLength, @@ -41,7 +43,7 @@ import { type ZoneNode as ZoneNodeType, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Command } from 'lucide-react' +import { Command, Ruler } from 'lucide-react' import { memo, type MouseEvent as ReactMouseEvent, @@ -53,9 +55,11 @@ import { useState, } from 'react' import { createPortal } from 'react-dom' +import { useShallow } from 'zustand/react/shallow' import { buildFloorplanItemEntry, buildFloorplanStairEntry as buildSharedFloorplanStairEntry, + collectLevelDescendants, getFloorplanWall as getSharedFloorplanWall, rotatePlanVector as rotateSharedPlanVector, type FloorplanNodeTransform as SharedFloorplanNodeTransform, @@ -64,6 +68,7 @@ import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' import { cn } from '../../lib/utils' +import type { GuideUiState } from '../../store/use-editor' import useEditor from '../../store/use-editor' import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' @@ -118,6 +123,8 @@ const PANEL_DEFAULT_BOTTOM_OFFSET = 96 const MIN_GRID_SCREEN_SPACING = 12 const GRID_COORDINATE_PRECISION = 6 const MAJOR_GRID_STEP = WALL_GRID_STEP * 2 +const FLOORPLAN_MINOR_GRID_STROKE_WIDTH = 0.14 +const FLOORPLAN_MAJOR_GRID_STROKE_WIDTH = 0.26 const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18 const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13 const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035 @@ -127,14 +134,32 @@ const EDITOR_CURSOR = "url('/cursor.svg') 4 2, default" const FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT = 18 const FLOORPLAN_CURSOR_BADGE_OFFSET_X = 14 const FLOORPLAN_CURSOR_BADGE_OFFSET_Y = 14 -const FLOORPLAN_CURSOR_MARKER_CORE_RADIUS = 0.06 -const FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS = 0.2 +const FLOORPLAN_CURSOR_MARKER_CORE_RADIUS_PX = 3 +const FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS_PX = 10 +const FLOORPLAN_DRAFT_ANCHOR_RADIUS_PX = 7 +const FLOORPLAN_ENDPOINT_HANDLE_RADIUS_PX = 7 +const FLOORPLAN_ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8 +const FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9 +const FLOORPLAN_ENDPOINT_HANDLE_DOT_RADIUS_PX = 3 +const FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX = 4 +const FLOORPLAN_CURVE_HANDLE_DOT_RADIUS_PX = 3 +const FLOORPLAN_POLYGON_VERTEX_RADIUS_PX = 6.5 +const FLOORPLAN_POLYGON_VERTEX_ACTIVE_RADIUS_PX = 7.5 +const FLOORPLAN_POLYGON_VERTEX_DOT_RADIUS_PX = 2.5 +const FLOORPLAN_POLYGON_VERTEX_ACTIVE_DOT_RADIUS_PX = 3 +const FLOORPLAN_POLYGON_MIDPOINT_RADIUS_PX = 4 +const FLOORPLAN_POLYGON_MIDPOINT_HOVER_RADIUS_PX = 4.6 +const FLOORPLAN_POLYGON_MIDPOINT_DOT_RADIUS_PX = 1.8 const FLOORPLAN_MARQUEE_OUTLINE_WIDTH = 0.055 const FLOORPLAN_MARQUEE_GLOW_WIDTH = 0.14 const FLOORPLAN_HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)' const FLOORPLAN_WALL_HIT_STROKE_WIDTH = 18 const FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH = 18 const FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH = 8 +const FLOORPLAN_ITEM_HOVER_GLOW_STROKE_WIDTH = 6 +const FLOORPLAN_ITEM_HOVER_RING_STROKE_WIDTH = 2 +const FLOORPLAN_WALL_STROKE_WIDTH = '1' +const FLOORPLAN_SELECTED_WALL_STROKE_WIDTH = '1.5' const FLOORPLAN_OPENING_HIT_STROKE_WIDTH = 16 const FLOORPLAN_OPENING_STROKE_WIDTH = 0.05 const FLOORPLAN_OPENING_DETAIL_STROKE_WIDTH = 0.02 @@ -148,6 +173,7 @@ const FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT = 0.08 const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH = 0 const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY = 0 const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15 +const FLOORPLAN_SLAB_LABEL_FONT_SIZE = 0.2 const FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH = 0 const FLOORPLAN_MEASUREMENT_LABEL_GAP = 0.56 const FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING = 0.14 @@ -182,6 +208,9 @@ const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92 const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48 const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45 const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1 +const FLOORPLAN_TRACE_SURFACE_FILL_OPACITY = 0.08 +const FLOORPLAN_TRACE_STRUCTURE_FILL_OPACITY = 0.22 +const FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY = 0.34 const FLOORPLAN_SITE_COLOR = '#10b981' const FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH = FLOORPLAN_OPENING_STROKE_WIDTH / 2 const FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH = FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH * 0.7 @@ -314,6 +343,21 @@ type GuideTransformDraft = { rotation: number } +type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches' + +type ReferenceScaleDraft = { + guideId: GuideNode['id'] + start: WallPlanPoint | null + cursor: WallPlanPoint | null +} + +type PendingReferenceScale = { + guideId: GuideNode['id'] + start: WallPlanPoint + end: WallPlanPoint + measuredLengthUnits: number +} + type GuideHandleHintAnchor = { x: number y: number @@ -351,12 +395,67 @@ type WallCurveDraft = { type SlabBoundaryDraft = { slabId: SlabNode['id'] polygon: WallPlanPoint[] + visualOffsets?: Point2D[] +} + +type SlabHoleBoundaryDraft = { + slabId: SlabNode['id'] + holeIndex: number + polygon: WallPlanPoint[] } type SlabVertexDragState = { pointerId: number slabId: SlabNode['id'] vertexIndex: number + visualOffset: Point2D +} + +type SlabHoleVertexDragState = { + pointerId: number + slabId: SlabNode['id'] + holeIndex: number + vertexIndex: number +} + +type SlabHoleMoveDraft = { + slabId: SlabNode['id'] + holeIndex: number + polygon: WallPlanPoint[] + originalPolygon: WallPlanPoint[] + startPlanPoint: WallPlanPoint +} + +type CeilingBoundaryDraft = { + ceilingId: CeilingNode['id'] + polygon: WallPlanPoint[] +} + +type CeilingVertexDragState = { + pointerId: number + ceilingId: CeilingNode['id'] + vertexIndex: number +} + +type CeilingHoleBoundaryDraft = { + ceilingId: CeilingNode['id'] + holeIndex: number + polygon: WallPlanPoint[] +} + +type CeilingHoleVertexDragState = { + pointerId: number + ceilingId: CeilingNode['id'] + holeIndex: number + vertexIndex: number +} + +type CeilingHoleMoveDraft = { + ceilingId: CeilingNode['id'] + holeIndex: number + polygon: WallPlanPoint[] + originalPolygon: WallPlanPoint[] + startPlanPoint: WallPlanPoint } type SiteBoundaryDraft = { @@ -407,9 +506,42 @@ type SlabPolygonEntry = { slab: SlabNode polygon: Point2D[] holes: Point2D[][] + visualPolygon: Point2D[] + visualHoles: Point2D[][] path: string } +function getSlabHandlePolygon(entry: SlabPolygonEntry) { + return entry.visualPolygon.length === entry.polygon.length ? entry.visualPolygon : entry.polygon +} + +function getSlabVisualOffsets(entry: SlabPolygonEntry): Point2D[] { + const handlePolygon = getSlabHandlePolygon(entry) + + return entry.polygon.map((point) => { + const handlePoint = + handlePolygon.length > 0 + ? handlePolygon[getClosestPolygonVertexIndex(point, handlePolygon)] + : point + + return { + x: (handlePoint?.x ?? point.x) - point.x, + y: (handlePoint?.y ?? point.y) - point.y, + } + }) +} + +function getDraftSlabVisualPolygon(draft: SlabBoundaryDraft): Point2D[] { + return draft.polygon.map(([x, y], index) => { + const offset = draft.visualOffsets?.[index] + + return { + x: x + (offset?.x ?? 0), + y: y + (offset?.y ?? 0), + } + }) +} + type CeilingPolygonEntry = { ceiling: CeilingNode polygon: Point2D[] @@ -454,6 +586,15 @@ type FloorplanItemEntry = { depth: number } +type ReferenceFloorData = { + ceilingPolygons: CeilingPolygonEntry[] + fenceEntries: FloorplanFenceEntry[] + itemEntries: FloorplanItemEntry[] + openingPolygons: OpeningPolygonEntry[] + slabPolygons: SlabPolygonEntry[] + wallPolygons: WallPolygonEntry[] +} + type FloorplanStairSegmentEntry = { centerLine: FloorplanLineSegment | null innerPoints: string @@ -523,6 +664,20 @@ type FloorplanPalette = { openingFill: string openingStroke: string measurementStroke: string + roofFill: string + roofActiveFill: string + roofSelectedFill: string + roofStroke: string + roofActiveStroke: string + roofSelectedStroke: string + roofRidgeStroke: string + roofSelectedRidgeStroke: string + stairFill: string + stairSelectedFill: string + stairStroke: string + stairAccent: string + stairTread: string + stairSelectedTread: string endpointHandleFill: string endpointHandleStroke: string endpointHandleHoverStroke: string @@ -758,6 +913,14 @@ function getGuideRotateCursor(isDarkMode: boolean) { return buildCursorUrl(svgMarkup, 12, 12, 'pointer') } +function getGuideSvgRotation(rotationY: number) { + return normalizeAngle(Math.PI - rotationY) +} + +function getGuideSceneRotationFromSvgRotation(rotationSvg: number) { + return normalizeAngle(Math.PI - rotationSvg) +} + function buildGuideTranslateDraft( interaction: GuideInteractionState, pointerSvg: SvgPoint, @@ -771,7 +934,7 @@ function buildGuideTranslateDraft( guideId: interaction.guideId, position: toPlanPointFromSvgPoint(centerSvg), scale: interaction.scale, - rotation: normalizeAngle(-interaction.rotationSvg), + rotation: getGuideSceneRotationFromSvgRotation(interaction.rotationSvg), } } @@ -820,6 +983,56 @@ function doesGuideMatchDraft(guide: GuideNode, draft: GuideTransformDraft, epsil ) } +function transformGuideReferencePoint( + point: WallPlanPoint, + guide: GuideNode, + draft: GuideTransformDraft, +): WallPlanPoint { + const oldCenterSvg = getGuideCenterSvgPoint(guide) + const newCenterSvg: SvgPoint = { + x: toSvgX(draft.position[0]), + y: toSvgY(draft.position[1]), + } + const oldRotationSvg = getGuideSvgRotation(guide.rotation[1]) + const newRotationSvg = getGuideSvgRotation(draft.rotation) + const oldScale = guide.scale > 0 ? guide.scale : 1 + const newScale = draft.scale > 0 ? draft.scale : oldScale + const pointSvg = toSvgPlanPoint(point) + const localUnrotated = rotateVector(subtractSvgPoints(pointSvg, oldCenterSvg), -oldRotationSvg) + const localScaled: WallPlanPoint = [ + (localUnrotated[0] / oldScale) * newScale, + (localUnrotated[1] / oldScale) * newScale, + ] + const nextSvg = addVectorToSvgPoint(newCenterSvg, rotateVector(localScaled, newRotationSvg)) + + return toPlanPointFromSvgPoint(nextSvg) +} + +function transformGuideScaleReference( + guide: GuideNode, + draft: GuideTransformDraft, +): GuideNode['scaleReference'] { + const reference = guide.scaleReference + if (!reference) { + return reference + } + + const start = transformGuideReferencePoint(reference.start, guide, draft) + const end = transformGuideReferencePoint(reference.end, guide, draft) + const measuredLengthUnits = Math.hypot(end[0] - start[0], end[1] - start[1]) + + return { + ...reference, + start, + end, + measuredLengthUnits, + metersPerUnit: + measuredLengthUnits > 0 + ? reference.realLengthMeters / measuredLengthUnits + : reference.metersPerUnit, + } +} + function buildGuideResizeDraft( interaction: GuideInteractionState, pointerSvg: SvgPoint, @@ -847,7 +1060,7 @@ function buildGuideResizeDraft( guideId: interaction.guideId, position: toPlanPointFromSvgPoint(centerSvg), scale: width / FLOORPLAN_GUIDE_BASE_WIDTH, - rotation: normalizeAngle(-interaction.rotationSvg), + rotation: getGuideSceneRotationFromSvgRotation(interaction.rotationSvg), } } @@ -863,7 +1076,7 @@ function buildGuideRotationDraft( guideId: interaction.guideId, position: toPlanPointFromSvgPoint(interaction.centerSvg), scale: interaction.scale, - rotation: normalizeAngle(-interaction.rotationSvg), + rotation: getGuideSceneRotationFromSvgRotation(interaction.rotationSvg), } } @@ -880,7 +1093,7 @@ function buildGuideRotationDraft( guideId: interaction.guideId, position: toPlanPointFromSvgPoint(interaction.centerSvg), scale: interaction.scale, - rotation: normalizeAngle(-snappedRotationSvg), + rotation: getGuideSceneRotationFromSvgRotation(snappedRotationSvg), } } @@ -1441,6 +1654,64 @@ function getPlanPointDistance(start: Point2D, end: Point2D): number { return Math.hypot(end.x - start.x, end.y - start.y) } +function getPointToSegmentDistanceSquared(point: Point2D, start: Point2D, end: Point2D): number { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + if (lengthSquared <= Number.EPSILON) { + return (point.x - start.x) ** 2 + (point.y - start.y) ** 2 + } + + const t = clamp(((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared, 0, 1) + const projection = { + x: start.x + dx * t, + y: start.y + dy * t, + } + + return (point.x - projection.x) ** 2 + (point.y - projection.y) ** 2 +} + +function getClosestPolygonEdgeIndex(point: Point2D, polygon: Point2D[]): number { + let closestIndex = 0 + let closestDistanceSquared = Number.POSITIVE_INFINITY + + for (let index = 0; index < polygon.length; index += 1) { + const start = polygon[index] + const end = polygon[(index + 1) % polygon.length] + if (!(start && end)) { + continue + } + + const distanceSquared = getPointToSegmentDistanceSquared(point, start, end) + if (distanceSquared < closestDistanceSquared) { + closestDistanceSquared = distanceSquared + closestIndex = index + } + } + + return closestIndex +} + +function getClosestPolygonVertexIndex(point: Point2D, polygon: Point2D[]): number { + let closestIndex = 0 + let closestDistanceSquared = Number.POSITIVE_INFINITY + + for (let index = 0; index < polygon.length; index += 1) { + const vertex = polygon[index] + if (!vertex) { + continue + } + + const distanceSquared = (point.x - vertex.x) ** 2 + (point.y - vertex.y) ** 2 + if (distanceSquared < closestDistanceSquared) { + closestDistanceSquared = distanceSquared + closestIndex = index + } + } + + return closestIndex +} + function movePlanPointTowards(start: Point2D, end: Point2D, distance: number): Point2D { const totalDistance = getPlanPointDistance(start, end) if (totalDistance <= Number.EPSILON || distance <= 0) { @@ -1461,11 +1732,29 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { return baseSweepAngle } +function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { + if ( + (stair.stairType ?? 'straight') !== 'spiral' || + (stair.topLandingMode ?? 'none') !== 'integrated' + ) { + return 0 + } + + const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9) + const width = Math.max(stair.width ?? 1, 0.4) + const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8)) + + return ( + Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) * + Math.sign(sweepAngle || 1) + ) +} + function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] { const stairType = stair.stairType ?? 'straight' const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair) - const startAngle = stair.rotation - sweepAngle / 2 - const endAngle = startAngle + sweepAngle + const startAngle = -stair.rotation - sweepAngle / 2 + const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle) const center = { x: stair.position[0], y: stair.position[2], @@ -1783,15 +2072,54 @@ function getFloorplanWall(wall: WallNode): WallNode { } } -function formatMeasurement(value: number, unit: 'metric' | 'imperial') { +function formatMeasurement( + value: number, + unit: 'metric' | 'imperial', + metersPerUnit: number | null = null, +) { + const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value if (unit === 'imperial') { - const feet = value * 3.280_84 + const feet = measuredValue * 3.280_84 const wholeFeet = Math.floor(feet) const inches = Math.round((feet - wholeFeet) * 12) if (inches === 12) return `${wholeFeet + 1}'0"` return `${wholeFeet}'${inches}"` } - return `${Number.parseFloat(value.toFixed(2))}m` + return `${Number.parseFloat(measuredValue.toFixed(2))}m` +} + +function formatNumber(value: number, fractionDigits = 2) { + return Number.parseFloat(value.toFixed(fractionDigits)).toString() +} + +function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit) { + switch (unit) { + case 'centimeters': + return value / 100 + case 'feet': + return value * 0.3048 + case 'inches': + return value * 0.0254 + default: + return value + } +} + +function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) { + switch (unit) { + case 'centimeters': + return 'cm' + case 'feet': + return 'ft' + case 'inches': + return 'in' + default: + return 'm' + } +} + +function formatReferenceScaleLabel(value: number, unit: ReferenceScaleUnit) { + return `${formatNumber(value)} ${getReferenceScaleUnitLabel(unit)}` } function getPolygonAreaAndCentroid(polygon: Point2D[]) { @@ -1829,12 +2157,20 @@ function getSlabArea(polygon: Point2D[], holes: Point2D[][]) { return { area: Math.max(0, totalArea), centroid: outer.centroid } } -function formatArea(areaSqM: number, unit: 'metric' | 'imperial') { +function formatArea( + areaSqM: number, + unit: 'metric' | 'imperial', + metersPerUnit: number | null = null, +) { + const scaledAreaSqM = + metersPerUnit && metersPerUnit > 0 ? areaSqM * metersPerUnit * metersPerUnit : areaSqM + if (unit === 'imperial') { - const areaSqFt = areaSqM * 10.763_910_4 + const areaSqFt = scaledAreaSqM * 10.763_910_4 return ( <> - {Math.round(areaSqFt).toLocaleString()} ft + {Math.round(areaSqFt).toLocaleString()} + ft 2 @@ -1843,7 +2179,8 @@ function formatArea(areaSqM: number, unit: 'metric' | 'imperial') { } return ( <> - {Number.parseFloat(areaSqM.toFixed(1))} m + {Number.parseFloat(scaledAreaSqM.toFixed(1))} + m 2 @@ -1856,6 +2193,7 @@ function getWallMeasurementOverlay( centerX: number, centerZ: number, unit: 'metric' | 'imperial', + metersPerUnit: number | null = null, ): LinearMeasurementOverlay | null { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] @@ -1874,7 +2212,7 @@ function getWallMeasurementOverlay( const dot = cx * nx + cz * nz const outX = dot >= 0 ? nx : -nx const outZ = dot >= 0 ? nz : -nz - const label = formatMeasurement(length, unit) + const label = formatMeasurement(length, unit, metersPerUnit) const dimensionLine = { x1: toSvgX(wall.start[0] + outX * FLOORPLAN_MEASUREMENT_OFFSET), y1: toSvgY(wall.start[1] + outZ * FLOORPLAN_MEASUREMENT_OFFSET), @@ -2218,6 +2556,7 @@ function getSelectedWallMeasurementOverlays( selectedWallEntry: WallPolygonEntry, wallPolygons: WallPolygonEntry[], unit: 'metric' | 'imperial', + metersPerUnit: number | null = null, ): LinearMeasurementOverlay[] { const { wall } = selectedWallEntry @@ -2236,7 +2575,7 @@ function getSelectedWallMeasurementOverlays( const centerX = minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2 const centerY = minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2 - const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit) + const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit, metersPerUnit) return overlay ? [overlay] : [] } @@ -2256,7 +2595,7 @@ function getSelectedWallMeasurementOverlays( const centerX = minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2 const centerY = minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2 - const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit) + const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit, metersPerUnit) return overlay ? [overlay] : [] } @@ -2276,7 +2615,7 @@ function getSelectedWallMeasurementOverlays( `${wall.id}:outer-face`, outerFace.start, outerFace.end, - formatMeasurement(outerLength, unit), + formatMeasurement(outerLength, unit, metersPerUnit), { offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, offsetVector: outwardNormal, @@ -2298,7 +2637,7 @@ function getSelectedWallMeasurementOverlays( `${wall.id}:inner-face`, innerFace.start, innerFace.end, - formatMeasurement(innerLength, unit), + formatMeasurement(innerLength, unit, metersPerUnit), { offsetDistance: FLOORPLAN_WALL_INNER_MEASUREMENT_OFFSET, offsetVector: inwardNormal, @@ -2347,7 +2686,11 @@ function getItemDimensionMeasurementOverlays( itemEntry.item.scale[2] * itemEntry.item.asset.dimensions[2], unit, ) - const buildSideOverlay = (id: string, start: Point2D, end: Point2D) => { + const buildSideOverlay = ( + id: string, + start: Point2D, + end: Point2D, + ): LinearMeasurementOverlay | null => { const edgeVector = { x: end.x - start.x, y: end.y - start.y, @@ -2399,7 +2742,7 @@ function getItemDimensionMeasurementOverlays( : null } - const widthCandidates = [ + const widthCandidates: LinearMeasurementOverlay[] = [ polygon[0] && polygon[1] ? buildSideOverlay(`${itemEntry.item.id}:width-a`, polygon[0], polygon[1]) : null, @@ -2408,7 +2751,7 @@ function getItemDimensionMeasurementOverlays( : null, ].filter((overlay): overlay is LinearMeasurementOverlay => overlay !== null) - const depthCandidates = [ + const depthCandidates: LinearMeasurementOverlay[] = [ polygon[1] && polygon[2] ? buildSideOverlay(`${itemEntry.item.id}:depth-a`, polygon[1], polygon[2]) : null, @@ -2498,6 +2841,21 @@ function getOpeningCenterLine(polygon: Point2D[]) { } } +function isOpeningPlanFlipped(rotation: [number, number, number]) { + const normalized = + ((((rotation[1] % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) + 1e-6) % (Math.PI * 2) + + return normalized > Math.PI / 2 && normalized < (Math.PI * 3) / 2 +} + +function getFlippedHingesSide(hingesSide: DoorNode['hingesSide']) { + return hingesSide === 'left' ? 'right' : 'left' +} + +function getFlippedSwingDirection(swingDirection: DoorNode['swingDirection']) { + return swingDirection === 'inward' ? 'outward' : 'inward' +} + function normalizeGridCoordinate(value: number): number { return Number(value.toFixed(GRID_COORDINATE_PRECISION)) } @@ -2734,7 +3092,7 @@ function FloorplanGuideImage({ const planHeight = getGuideHeight(planWidth, aspectRatio) const centerX = toSvgX(guide.position[0]) const centerY = toSvgY(guide.position[2]) - const rotationDeg = (-guide.rotation[1] * 180) / Math.PI + const rotationDeg = (getGuideSvgRotation(guide.rotation[1]) * 180) / Math.PI return ( @@ -2918,7 +3276,7 @@ const FloorplanGridLayer = memo(function FloorplanGridLayer({ opacity={palette.majorGridOpacity} shapeRendering="crispEdges" stroke={palette.majorGrid} - strokeWidth="0.04" + strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH} vectorEffect="non-scaling-stroke" /> @@ -2926,6 +3284,7 @@ const FloorplanGridLayer = memo(function FloorplanGridLayer({ }) const FloorplanGuideLayer = memo(function FloorplanGuideLayer({ + guideUi, guides, isInteractive, selectedGuideId, @@ -2934,6 +3293,7 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({ onGuideSelect, onGuideTranslateStart, }: { + guideUi: Record guides: GuideNode[] isInteractive: boolean selectedGuideId: GuideNode['id'] | null @@ -2962,7 +3322,7 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({ activeGuideInteractionGuideId === guide.id ? activeGuideInteractionMode : null } guide={guide} - isInteractive={isInteractive} + isInteractive={isInteractive && guideUi[guide.id]?.locked !== true} isSelected={selectedGuideId === guide.id} key={guide.id} onGuideSelect={onGuideSelect} @@ -2973,6 +3333,146 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({ ) }) +function FloorplanReferenceScaleLine({ + end, + isDraft = false, + label, + palette, + start, + unitsPerPixel, +}: { + end: WallPlanPoint + isDraft?: boolean + label: string + palette: FloorplanPalette + start: WallPlanPoint + unitsPerPixel: number +}) { + const x1 = toSvgX(start[0]) + const y1 = toSvgY(start[1]) + const x2 = toSvgX(end[0]) + const y2 = toSvgY(end[1]) + const labelX = (x1 + x2) / 2 + const labelY = (y1 + y2) / 2 + const markerRadius = Math.max(unitsPerPixel * 5, 0.04) + const labelPaddingX = Math.max(unitsPerPixel * 8, 0.08) + const labelWidth = Math.max( + label.length * unitsPerPixel * 7.2 + labelPaddingX * 2, + unitsPerPixel * 54, + ) + + return ( + + + + + + + + {label} + + + + ) +} + +function FloorplanReferenceScaleLayer({ + draft, + guideUi, + guides, + palette, + unit, + unitsPerPixel, +}: { + draft: ReferenceScaleDraft | null + guideUi: Record + guides: GuideNode[] + palette: FloorplanPalette + unit: 'metric' | 'imperial' + unitsPerPixel: number +}) { + const visibleReferences = guides + .filter((guide) => guideUi[guide.id]?.scaleReferenceVisible !== false) + .map((guide) => guide.scaleReference) + .filter((reference): reference is NonNullable => + Boolean(reference), + ) + + return ( + <> + {visibleReferences.map((reference, index) => ( + + ))} + {draft?.start && draft.cursor && ( + + )} + + ) +} + function FloorplanGuideSelectionOverlay({ guide, isDarkMode, @@ -3005,7 +3505,7 @@ function FloorplanGuideSelectionOverlay({ const planHeight = getGuideHeight(planWidth, aspectRatio) const centerX = toSvgX(guide.position[0]) const centerY = toSvgY(guide.position[2]) - const rotationDeg = (-guide.rotation[1] * 180) / Math.PI + const rotationDeg = (getGuideSvgRotation(guide.rotation[1]) * 180) / Math.PI const selectionStroke = isDarkMode ? '#ffffff' : '#09090b' const handleFill = isDarkMode ? '#ffffff' : '#09090b' const handleStroke = isDarkMode ? '#0a0e1b' : '#ffffff' @@ -3063,7 +3563,7 @@ function FloorplanGuideSelectionOverlay({ style={{ cursor: rotationModifierPressed ? getGuideRotateCursor(isDarkMode) - : getGuideResizeCursor(corner, -guide.rotation[1]), + : getGuideResizeCursor(corner, getGuideSvgRotation(guide.rotation[1])), }} vectorEffect="non-scaling-stroke" /> @@ -3144,6 +3644,97 @@ function FloorplanGuideHandleHint({ ) } +const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer({ + data, + opacity, +}: { + data: ReferenceFloorData | null + opacity: number +}) { + if (!data) { + return null + } + + const clampedOpacity = clamp(opacity, 0.1, 0.8) + + return ( + + {data.slabPolygons.map(({ path, slab }) => ( + + ))} + + {data.ceilingPolygons.map(({ ceiling, path }) => ( + + ))} + + {data.wallPolygons.map(({ polygon, points, wall }) => + polygon.length >= 3 ? ( + + ) : null, + )} + + {data.fenceEntries.map(({ fence, path }) => ( + + ))} + + {data.openingPolygons.map(({ opening, points }) => ( + + ))} + + {data.itemEntries.map(({ item, points }) => ( + + ))} + + ) +}) + const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ canFocusGeometry, canSelectGeometry, @@ -3172,10 +3763,13 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ openingsPolygons, palette, selectedIdSet, + slabSelectionHatchId, slabPolygons, wallPolygons, wallSelectionHatchId, unit, + metersPerUnit, + isGuideTraceVisible, }: { canFocusGeometry: boolean canSelectSlabs: boolean @@ -3204,20 +3798,28 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ openingsPolygons: OpeningPolygonEntry[] palette: FloorplanPalette selectedIdSet: ReadonlySet + slabSelectionHatchId: string slabPolygons: SlabPolygonEntry[] wallPolygons: WallPolygonEntry[] wallSelectionHatchId: string unit: 'metric' | 'imperial' + metersPerUnit: number | null + isGuideTraceVisible: boolean }) { const selectedWallEntries = wallPolygons.filter(({ wall }) => selectedIdSet.has(wall.id)) const wallMeasurements = selectedIdSet.size === 1 && selectedWallEntries.length === 1 - ? getSelectedWallMeasurementOverlays(selectedWallEntries[0]!, wallPolygons, unit) + ? getSelectedWallMeasurementOverlays( + selectedWallEntries[0]!, + wallPolygons, + unit, + metersPerUnit, + ) : [] return ( <> - {slabPolygons.map(({ slab, polygon, holes, path }) => { + {slabPolygons.map(({ slab, polygon, visualPolygon, visualHoles, path }) => { const isSelected = selectedIdSet.has(slab.id) const isHighlighted = highlightedIdSet.has(slab.id) const isDeleteHovered = isDeleteMode && hoveredSlabId === slab.id @@ -3227,18 +3829,25 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ : showSelectedSlabStyle ? palette.selectedSlabStroke : palette.slabStroke - const slabBorderWidth = showSelectedSlabStyle ? '0.065' : '0.05' + const slabBorderWidth = showSelectedSlabStyle ? '1.2' : '1' + const slabFillOpacity = isDeleteHovered + ? 1 + : isGuideTraceVisible + ? showSelectedSlabStyle + ? FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY + : FLOORPLAN_TRACE_STRUCTURE_FILL_OPACITY + : 1 let slabLabel = null if (isSelected) { - const { area, centroid } = getSlabArea(polygon, holes) + const { area, centroid } = getSlabArea(visualPolygon, visualHoles) if (area > 0) { slabLabel = ( - {formatArea(area, unit)} + {formatArea(area, unit, metersPerUnit)} ) } @@ -3262,14 +3871,18 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ + { @@ -3292,6 +3905,16 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined} stroke="none" /> + {isSelected && !isDeleteHovered ? ( + + ) : null} @@ -3319,21 +3942,23 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ : showSelectedCeilingStyle ? palette.selectedCeilingStroke : palette.ceilingStroke - const ceilingBorderWidth = showSelectedCeilingStyle ? '0.065' : '0.05' + const ceilingBorderWidth = showSelectedCeilingStyle ? '1.2' : '1' + const ceilingFillOpacity = isDeleteHovered + ? 1 + : isGuideTraceVisible + ? showSelectedCeilingStyle + ? FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY + : FLOORPLAN_TRACE_STRUCTURE_FILL_OPACITY + : 1 return ( { @@ -3358,16 +3983,25 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined} stroke="none" /> - + ) : null} + @@ -3416,7 +4050,13 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ /> )} { @@ -3436,7 +4076,11 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ points={points} stroke={wallStroke} strokeOpacity={1} - strokeWidth={showSelectedWallChrome ? '1.5' : '1'} + strokeWidth={ + showSelectedWallChrome + ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH + : FLOORPLAN_WALL_STROKE_WIDTH + } style={{ cursor: EDITOR_CURSOR }} vectorEffect="non-scaling-stroke" /> @@ -3510,6 +4154,137 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ const detailStrokeWidth = isSelected || isSelectionHighlighted ? '1.05' : '0.75' const markerX = (p1!.x + p2!.x + p3!.x + p4!.x) / 4 const markerY = (p1!.y + p2!.y + p3!.y + p4!.y) / 4 + const windowOpeningShape = opening.openingShape ?? 'rectangle' + + if (opening.openingKind === 'opening') { + const detailInset = Math.min(tangentLength * 0.14, 0.18) + const detailStart = { + x: centerLine.start.x + tangentX * detailInset, + y: centerLine.start.y + tangentY * detailInset, + } + const detailEnd = { + x: centerLine.end.x - tangentX * detailInset, + y: centerLine.end.y - tangentY * detailInset, + } + const detailControl = { + x: (detailStart.x + detailEnd.x) / 2 + normalX * normalLength * 0.34, + y: (detailStart.y + detailEnd.y) / 2 + normalY * normalLength * 0.34, + } + const detailPath = + windowOpeningShape === 'rectangle' + ? null + : `M ${toSvgX(detailStart.x)} ${toSvgY(detailStart.y)} Q ${toSvgX(detailControl.x)} ${toSvgY(detailControl.y)} ${toSvgX(detailEnd.x)} ${toSvgY(detailEnd.y)}` + + return ( + { + event.stopPropagation() + onOpeningSelect(opening.id, event) + } + : undefined + } + onDoubleClick={ + canFocusGeometry + ? (event) => { + event.stopPropagation() + onOpeningDoubleClick(opening) + } + : undefined + } + onPointerDown={ + canFocusGeometry && isSelected + ? (event) => { + if (event.button === 0) { + onOpeningPointerDown(opening.id, event) + } + } + : undefined + } + onPointerEnter={ + canSelectGeometry + ? () => { + onWallHoverChange(null) + onOpeningHoverChange(opening.id) + } + : undefined + } + onPointerLeave={canSelectGeometry ? () => onOpeningHoverChange(null) : undefined} + style={{ cursor: EDITOR_CURSOR }} + > + {canSelectGeometry && ( + + )} + + {detailPath ? ( + + ) : ( + + )} + {isSelected ? ( + <> + + + + + ) : null} + + ) + } return ( `${point.x},${point.y}`) + .join(' ') + const openingPlanPath = + opening.openingKind === 'opening' && opening.openingShape === 'rounded' + ? (() => { + const [a, b, c, d] = doorBackgroundPointList + if (!(a && b && c && d)) return null + + const tangentRadius = Math.min(width * 0.14, doorCubeSize * 1.6) + const depthRadius = Math.min( + Math.hypot(svgP4.x - svgP1.x, svgP4.y - svgP1.y) * 0.42, + doorCubeSize, + ) + const radius = Math.min(tangentRadius, depthRadius) + const offset = (from: Point2D, to: Point2D, distance: number) => { + const dx = to.x - from.x + const dy = to.y - from.y + const length = Math.hypot(dx, dy) + if (length < 1e-6) return from + return { + x: from.x + (dx / length) * Math.min(distance, length / 2), + y: from.y + (dy / length) * Math.min(distance, length / 2), + } + } + + const aToB = offset(a, b, radius) + const bToA = offset(b, a, radius) + const bToC = offset(b, c, radius) + const cToB = offset(c, b, radius) + const cToD = offset(c, d, radius) + const dToC = offset(d, c, radius) + const dToA = offset(d, a, radius) + const aToD = offset(a, d, radius) + + return [ + `M ${aToB.x} ${aToB.y}`, + `L ${bToA.x} ${bToA.y}`, + `Q ${b.x} ${b.y} ${bToC.x} ${bToC.y}`, + `L ${cToB.x} ${cToB.y}`, + `Q ${c.x} ${c.y} ${cToD.x} ${cToD.y}`, + `L ${dToC.x} ${dToC.y}`, + `Q ${d.x} ${d.y} ${dToA.x} ${dToA.y}`, + `L ${aToD.x} ${aToD.y}`, + `Q ${a.x} ${a.y} ${aToB.x} ${aToB.y}`, + 'Z', + ].join(' ') + })() + : null + const archPlanPath = + opening.openingKind === 'opening' && opening.openingShape === 'arch' + ? (() => { + const centerStart = { + x: (svgP1.x + svgP4.x) / 2, + y: (svgP1.y + svgP4.y) / 2, + } + const centerEnd = { + x: (svgP2.x + svgP3.x) / 2, + y: (svgP2.y + svgP3.y) / 2, + } + const midpoint = { + x: (centerStart.x + centerEnd.x) / 2, + y: (centerStart.y + centerEnd.y) / 2, + } + const bow = Math.min(width * 0.18, doorCubeSize * 1.8) + return `M ${centerStart.x} ${centerStart.y} Q ${midpoint.x + px * bow} ${ + midpoint.y + py * bow + } ${centerEnd.x} ${centerEnd.y}` + })() + : null + const leafPolygonPoints = [ + { + x: leafStart.x - nx * leafHalfThickness, + y: leafStart.y - ny * leafHalfThickness, + }, + { + x: leafEnd.x - nx * leafHalfThickness, + y: leafEnd.y - ny * leafHalfThickness, + }, + { + x: leafEnd.x + nx * leafHalfThickness, + y: leafEnd.y + ny * leafHalfThickness, + }, + { + x: leafStart.x + nx * leafHalfThickness, + y: leafStart.y + ny * leafHalfThickness, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + const swingSweepPath = + swingRadius > 1e-6 + ? `M ${leafStart.x} ${leafStart.y} L ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y} Z` + : null + const jambTickSize = doorCubeSize * 0.82 + const hingeMarkerRadius = Math.min(Math.max(doorCubeSize * 0.22, 0.018), 0.034) + const strikeTickStart = { + x: strikeCubeCenter.x - px * swingSign * jambTickSize * 0.5, + y: strikeCubeCenter.y - py * swingSign * jambTickSize * 0.5, + } + const strikeTickEnd = { + x: strikeCubeCenter.x + px * swingSign * jambTickSize * 0.5, + y: strikeCubeCenter.y + py * swingSign * jambTickSize * 0.5, + } + const closedLeafHintPoints = [ + { + x: leafStart.x - nx * leafHalfThickness * 0.7, + y: leafStart.y - ny * leafHalfThickness * 0.7, + }, + { + x: arcEnd.x - nx * leafHalfThickness * 0.7, + y: arcEnd.y - ny * leafHalfThickness * 0.7, + }, + { + x: arcEnd.x + nx * leafHalfThickness * 0.7, + y: arcEnd.y + ny * leafHalfThickness * 0.7, + }, + { + x: leafStart.x + nx * leafHalfThickness * 0.7, + y: leafStart.y + ny * leafHalfThickness * 0.7, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + const openingCenterLineStart = { + x: (svgP1.x + svgP4.x) / 2, + y: (svgP1.y + svgP4.y) / 2, + } + const openingCenterLineEnd = { + x: (svgP2.x + svgP3.x) / 2, + y: (svgP2.y + svgP3.y) / 2, + } return ( )} - - {[centerLine.start, centerLine.end].map((point, index) => { - const svgPoint = toSvgPoint(point) - const jambSize = 0.18 - return ( + {opening.openingKind === 'opening' ? ( + <> + {openingPlanPath ? ( + + ) : ( + + )} - ) - })} - - - + {archPlanPath && ( + + )} + + ) : ( + <> + + {swingSweepPath && ( + + )} + {swingAngle > 0.03 && ( + + )} + {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( + + ))} + + + + + + )} {isSelected ? ( <> = { + 'office-chair': '/items/office-chair/floor-plan.svg', + sofa: '/items/sofa/floor-plan.svg', +} +const FLOORPLAN_ITEMS_WITH_SELF_OUTLINED_ICON = new Set(['office-chair', 'sofa']) + +function getFloorplanItemIconUrl(item: ItemNode) { + return FLOORPLAN_ITEM_ICON_OVERRIDES[item.asset.id] ?? item.asset.floorPlanUrl +} + function FloorplanItemImage({ url, center, @@ -4165,6 +5224,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ selectedIdSet, stairEntries, unit, + wallSelectionHatchId, }: { canFocusItems: boolean canFocusStairs: boolean @@ -4190,6 +5250,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ selectedIdSet: ReadonlySet stairEntries: FloorplanStairEntry[] unit: 'metric' | 'imperial' + wallSelectionHatchId: string }) { if (itemEntries.length === 0 && stairEntries.length === 0) { return null @@ -4203,22 +5264,18 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ const isHovered = hoveredItemId === item.id const isDeleteHovered = isDeleteMode && isHovered const isSelectionActive = isSelected || isHighlighted - const showHighlight = isDeleteHovered || isSelectionActive || isHovered + const showHighlight = isDeleteHovered || (isHovered && !isSelectionActive) const stroke = isDeleteHovered ? palette.deleteStroke : isSelectionActive ? palette.selectedStroke - : palette.openingStroke + : palette.wallStroke const highlightStroke = isDeleteHovered ? palette.deleteStroke : isSelectionActive ? palette.selectedStroke : palette.wallHoverStroke - const fill = isDeleteHovered - ? palette.deleteFill - : isSelectionActive - ? palette.selectedFill - : palette.openingFill + const fill = isDeleteHovered ? palette.deleteFill : palette.openingFill const crossStrokeOpacity = isDeleteHovered ? 0.76 : isSelectionActive @@ -4226,7 +5283,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ : isHovered ? 0.58 : 0.52 - const floorPlanUrl = item.asset.floorPlanUrl + const floorPlanUrl = getFloorplanItemIconUrl(item) + const shouldDrawFootprintBorder = !FLOORPLAN_ITEMS_WITH_SELF_OUTLINED_ICON.has(item.asset.id) const diagonalAStart = polygon[0] const diagonalAEnd = polygon[2] const diagonalBStart = polygon[1] @@ -4272,8 +5330,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ points={points} stroke={highlightStroke} strokeLinejoin="round" - strokeOpacity={isDeleteHovered || isSelectionActive ? 0.22 : 0.16} - strokeWidth={FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH} + strokeOpacity={isDeleteHovered || isSelectionActive ? 0.18 : 0.12} + strokeWidth={FLOORPLAN_ITEM_HOVER_GLOW_STROKE_WIDTH} style={{ opacity: showHighlight ? 1 : 0, transition: FLOORPLAN_HOVER_TRANSITION, @@ -4286,8 +5344,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ points={points} stroke={highlightStroke} strokeLinejoin="round" - strokeOpacity={isDeleteHovered || isSelectionActive ? 0.6 : 0.48} - strokeWidth={FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH} + strokeOpacity={isDeleteHovered || isSelectionActive ? 0.58 : 0.44} + strokeWidth={FLOORPLAN_ITEM_HOVER_RING_STROKE_WIDTH} style={{ opacity: showHighlight ? 1 : 0, transition: FLOORPLAN_HOVER_TRANSITION, @@ -4310,10 +5368,16 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ : 0.015 } points={points} - stroke={stroke} - strokeLinejoin="round" - strokeOpacity={1} - strokeWidth={FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH} + stroke={shouldDrawFootprintBorder ? stroke : 'none'} + strokeOpacity={shouldDrawFootprintBorder ? 1 : 0} + strokeWidth={ + shouldDrawFootprintBorder + ? isSelectionActive + ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH + : FLOORPLAN_WALL_STROKE_WIDTH + : 0 + } + vectorEffect="non-scaling-stroke" /> {floorPlanUrl ? ( )} + {isSelected && !isDeleteHovered ? ( + + ) : null} {itemDimensionMeasurements.length > 0 ? ( void palette: FloorplanPalette + unitsPerPixel: number }) { return ( <> @@ -4820,7 +5896,16 @@ const FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({ isSelected || isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleHoverStroke - const outerRadius = isActive ? 0.18 : isSelected ? 0.16 : 0.14 + const outerRadius = + (isActive + ? FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_RADIUS_PX + : isSelected + ? FLOORPLAN_ENDPOINT_HANDLE_SELECTED_RADIUS_PX + : FLOORPLAN_ENDPOINT_HANDLE_RADIUS_PX) * unitsPerPixel + const dotRadius = + (isActive + ? FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX + : FLOORPLAN_ENDPOINT_HANDLE_DOT_RADIUS_PX) * unitsPerPixel const svgPoint = toSvgPlanPoint(point) return ( @@ -4878,7 +5963,7 @@ const FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({ cy={svgPoint.y} fill={stroke} pointerEvents="none" - r={isActive ? 0.08 : 0.06} + r={dotRadius} vectorEffect="non-scaling-stroke" /> , ) => void palette: FloorplanPalette + unitsPerPixel: number }) { return ( <> @@ -4934,7 +6021,16 @@ const FloorplanFenceEndpointLayer = memo(function FloorplanFenceEndpointLayer({ isSelected || isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleHoverStroke - const outerRadius = isActive ? 0.18 : isSelected ? 0.16 : 0.14 + const outerRadius = + (isActive + ? FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_RADIUS_PX + : isSelected + ? FLOORPLAN_ENDPOINT_HANDLE_SELECTED_RADIUS_PX + : FLOORPLAN_ENDPOINT_HANDLE_RADIUS_PX) * unitsPerPixel + const dotRadius = + (isActive + ? FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX + : FLOORPLAN_ENDPOINT_HANDLE_DOT_RADIUS_PX) * unitsPerPixel const svgPoint = toSvgPlanPoint(point) return ( @@ -4992,7 +6088,7 @@ const FloorplanFenceEndpointLayer = memo(function FloorplanFenceEndpointLayer({ cy={svgPoint.y} fill={stroke} pointerEvents="none" - r={isActive ? 0.08 : 0.06} + r={dotRadius} vectorEffect="non-scaling-stroke" /> void onWallCurvePointerDown: (wall: WallNode, event: ReactPointerEvent) => void palette: FloorplanPalette + unitsPerPixel: number }) { return ( <> @@ -5039,7 +6137,11 @@ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLaye const stroke = palette.curveHandleStroke const hoverStroke = palette.curveHandleHoverStroke const svgPoint = toSvgPlanPoint(point) - const radius = isActive ? 0.16 : 0.14 + const radius = + (isActive + ? FLOORPLAN_ENDPOINT_HANDLE_SELECTED_RADIUS_PX + : FLOORPLAN_ENDPOINT_HANDLE_RADIUS_PX) * unitsPerPixel + const dotRadius = FLOORPLAN_CURVE_HANDLE_DOT_RADIUS_PX * unitsPerPixel return ( + midpointStyle?: 'default' | 'add' midpointHandles: Array<{ nodeId: string edgeIndex: number @@ -5142,6 +6247,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ event: ReactPointerEvent, ) => void palette: FloorplanPalette + unitsPerPixel: number }) { return ( <> @@ -5149,7 +6255,14 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ const handleId = `${nodeId}:vertex:${vertexIndex}` const isHovered = hoveredHandleId === handleId const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke - const outerRadius = isActive ? 0.15 : 0.13 + const outerRadius = + (isActive + ? FLOORPLAN_POLYGON_VERTEX_ACTIVE_RADIUS_PX + : FLOORPLAN_POLYGON_VERTEX_RADIUS_PX) * unitsPerPixel + const dotRadius = + (isActive + ? FLOORPLAN_POLYGON_VERTEX_ACTIVE_DOT_RADIUS_PX + : FLOORPLAN_POLYGON_VERTEX_DOT_RADIUS_PX) * unitsPerPixel const svgPoint = toSvgPlanPoint(point) return ( @@ -5192,7 +6305,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ cy={svgPoint.y} fill={stroke} pointerEvents="none" - r={isActive ? 0.058 : 0.05} + r={dotRadius} vectorEffect="non-scaling-stroke" /> { const handleId = `${nodeId}:midpoint:${edgeIndex}` const isHovered = hoveredHandleId === handleId - const stroke = isHovered ? palette.endpointHandleHoverStroke : palette.endpointHandleStroke - const radius = isHovered ? 0.092 : 0.08 + const isAddHandle = midpointStyle === 'add' + const stroke = isAddHandle + ? '#111827' + : isHovered + ? palette.endpointHandleHoverStroke + : palette.endpointHandleStroke + const radius = + (isAddHandle + ? isHovered + ? FLOORPLAN_POLYGON_VERTEX_ACTIVE_RADIUS_PX + : FLOORPLAN_POLYGON_VERTEX_RADIUS_PX + : isHovered + ? FLOORPLAN_POLYGON_MIDPOINT_HOVER_RADIUS_PX + : FLOORPLAN_POLYGON_MIDPOINT_RADIUS_PX) * unitsPerPixel + const dotRadius = isAddHandle ? 0 : FLOORPLAN_POLYGON_MIDPOINT_DOT_RADIUS_PX * unitsPerPixel + const plusHalfLength = 3 * unitsPerPixel const svgPoint = toSvgPlanPoint(point) return ( @@ -5239,7 +6366,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ cy={svgPoint.y} fill="none" pointerEvents="none" - r={radius + 0.03} + r={radius + 2 * unitsPerPixel} stroke={stroke} strokeOpacity={0.16} strokeWidth={FLOORPLAN_ENDPOINT_HOVER_RING_STROKE_WIDTH} @@ -5252,24 +6379,51 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ - + {isAddHandle ? ( + <> + + + + ) : ( + + )} (null) const siteBoundaryDraftRef = useRef(null) const slabBoundaryDraftRef = useRef(null) + const slabHoleBoundaryDraftRef = useRef(null) + const ceilingBoundaryDraftRef = useRef(null) + const ceilingHoleBoundaryDraftRef = useRef(null) const zoneBoundaryDraftRef = useRef(null) const gestureScaleRef = useRef(1) const panelInteractionRef = useRef(null) @@ -5340,6 +6497,8 @@ export function FloorplanPanel() { const setStructureLayer = useEditor((state) => state.setStructureLayer) const setTool = useEditor((state) => state.setTool) const tool = useEditor((state) => state.tool) + const editingHole = useEditor((state) => state.editingHole) + const setEditingHole = useEditor((state) => state.setEditingHole) const deleteNode = useScene((state) => state.deleteNode) const updateNode = useScene((state) => state.updateNode) const { @@ -5374,9 +6533,35 @@ export function FloorplanPanel() { const [siteVertexDragState, setSiteVertexDragState] = useState(null) const [slabBoundaryDraft, setSlabBoundaryDraft] = useState(null) const [slabVertexDragState, setSlabVertexDragState] = useState(null) + const [slabHoleBoundaryDraft, setSlabHoleBoundaryDraft] = useState( + null, + ) + const [slabHoleVertexDragState, setSlabHoleVertexDragState] = + useState(null) + const [slabHoleMoveDraft, setSlabHoleMoveDraft] = useState(null) + const [ceilingBoundaryDraft, setCeilingBoundaryDraft] = useState( + null, + ) + const [ceilingVertexDragState, setCeilingVertexDragState] = + useState(null) + const [ceilingHoleBoundaryDraft, setCeilingHoleBoundaryDraft] = + useState(null) + const [ceilingHoleVertexDragState, setCeilingHoleVertexDragState] = + useState(null) + const [ceilingHoleMoveDraft, setCeilingHoleMoveDraft] = useState( + null, + ) const [zoneBoundaryDraft, setZoneBoundaryDraft] = useState(null) const [zoneVertexDragState, setZoneVertexDragState] = useState(null) const [guideTransformDraft, setGuideTransformDraft] = useState(null) + const [referenceScaleDraft, setReferenceScaleDraft] = useState(null) + const [pendingReferenceScale, setPendingReferenceScale] = useState( + null, + ) + const [referenceScaleValue, setReferenceScaleValue] = useState('1') + const [referenceScaleUnit, setReferenceScaleUnit] = useState( + unit === 'imperial' ? 'feet' : 'meters', + ) const [cursorPoint, setCursorPoint] = useState(null) const [floorplanCursorPosition, setFloorplanCursorPosition] = useState(null) const [wallEndpointDraft, setWallEndpointDraft] = useState(null) @@ -5393,10 +6578,18 @@ export function FloorplanPanel() { const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState(null) const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState(null) const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState(null) + const [hoveredCeilingHandleId, setHoveredCeilingHandleId] = useState(null) const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState(null) const [hoveredGuideCorner, setHoveredGuideCorner] = useState(null) const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool) const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool) + const showReferenceFloor = useEditor((s) => s.showReferenceFloor) + const referenceFloorOffset = useEditor((s) => s.referenceFloorOffset) + const referenceFloorOpacity = useEditor((s) => s.referenceFloorOpacity) + const guideUi = useEditor((s) => s.guideUi) + const setGuideLocked = useEditor((s) => s.setGuideLocked) + const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible) + const clearGuideUi = useEditor((s) => s.clearGuideUi) const [floorplanMarqueeState, setFloorplanMarqueeState] = useState( null, ) @@ -5524,14 +6717,33 @@ export function FloorplanPanel() { : guide, ) }, [guideTransformDraft, visibleGuides]) + const isGuideTraceVisible = displayGuides.some((guide) => guide.opacity > 0 && guide.scale > 0) const selectedGuideId = selectedReferenceId && guideById.has(selectedReferenceId as GuideNode['id']) ? (selectedReferenceId as GuideNode['id']) : null const selectedGuide = useMemo( - () => displayGuides.find((guide) => guide.id === selectedGuideId) ?? null, - [displayGuides, selectedGuideId], + () => + displayGuides.find((guide) => guide.id === selectedGuideId) ?? + (selectedGuideId ? (guideById.get(selectedGuideId) ?? null) : null), + [displayGuides, guideById, selectedGuideId], ) + const calibratedMeasurementGuide = useMemo(() => { + if ( + selectedGuide?.scaleReference && + selectedGuide.scaleReference.metersPerUnit > 0 && + selectedGuide.visible !== false + ) { + return selectedGuide + } + + return ( + visibleGuides.find( + (guide) => guide.scaleReference && guide.scaleReference.metersPerUnit > 0, + ) ?? null + ) + }, [selectedGuide, visibleGuides]) + const calibratedMetersPerUnit = calibratedMeasurementGuide?.scaleReference?.metersPerUnit ?? null const selectedGuideResolvedUrl = useResolvedAssetUrl(selectedGuide?.url ?? '') const selectedGuideDimensions = useGuideImageDimensions(selectedGuideResolvedUrl) const activeGuideInteractionGuideId = guideTransformDraft @@ -5757,33 +6969,78 @@ export function FloorplanPanel() { const holes = (slab.holes ?? []) .map((hole) => toFloorplanPolygon(hole)) .filter((hole) => hole.length >= 3) + const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab)) + const visualHoles = holes return [ { slab, polygon, holes, - path: formatPolygonPath(polygon, holes), + visualPolygon, + visualHoles, + path: formatPolygonPath(visualPolygon, visualHoles), }, ] }), [slabs], ) const displaySlabPolygons = useMemo(() => { - if (!slabBoundaryDraft) { + if (!(slabBoundaryDraft || slabHoleBoundaryDraft || slabHoleMoveDraft)) { return slabPolygons } - return slabPolygons.map((entry) => - entry.slab.id === slabBoundaryDraft.slabId - ? { + return slabPolygons.map((entry) => { + let nextEntry = entry + + if (slabBoundaryDraft && entry.slab.id === slabBoundaryDraft.slabId) { + nextEntry = (() => { + const draftVisualPolygon = + slabBoundaryDraft.visualOffsets?.length === slabBoundaryDraft.polygon.length + ? getDraftSlabVisualPolygon(slabBoundaryDraft) + : toFloorplanPolygon( + getRenderableSlabPolygon({ + ...entry.slab, + polygon: slabBoundaryDraft.polygon, + }), + ) + + return { ...entry, polygon: slabBoundaryDraft.polygon.map(toPoint2D), - path: formatPolygonPath(slabBoundaryDraft.polygon.map(toPoint2D), entry.holes), + visualPolygon: draftVisualPolygon, + path: formatPolygonPath(draftVisualPolygon, entry.visualHoles), } - : entry, - ) - }, [slabBoundaryDraft, slabPolygons]) + })() + } + + const activeHoleDraft = + slabHoleBoundaryDraft && entry.slab.id === slabHoleBoundaryDraft.slabId + ? slabHoleBoundaryDraft + : slabHoleMoveDraft && entry.slab.id === slabHoleMoveDraft.slabId + ? slabHoleMoveDraft + : null + + if (activeHoleDraft) { + const draftHole = activeHoleDraft.polygon.map(toPoint2D) + const draftHoles = nextEntry.holes.map((hole, index) => + index === activeHoleDraft.holeIndex ? draftHole : hole, + ) + const draftVisualHoles = nextEntry.visualHoles.map((hole, index) => + index === activeHoleDraft.holeIndex ? draftHole : hole, + ) + + nextEntry = { + ...nextEntry, + holes: draftHoles, + visualHoles: draftVisualHoles, + path: formatPolygonPath(nextEntry.visualPolygon, draftVisualHoles), + } + } + + return nextEntry + }) + }, [slabBoundaryDraft, slabHoleBoundaryDraft, slabHoleMoveDraft, slabPolygons]) const ceilingPolygons = useMemo( () => ceilings.flatMap((ceiling) => { @@ -5807,6 +7064,46 @@ export function FloorplanPanel() { }), [ceilings], ) + const displayCeilingPolygons = useMemo(() => { + if (!(ceilingBoundaryDraft || ceilingHoleBoundaryDraft || ceilingHoleMoveDraft)) { + return ceilingPolygons + } + + return ceilingPolygons.map((entry) => { + let nextEntry = entry + + if (ceilingBoundaryDraft && entry.ceiling.id === ceilingBoundaryDraft.ceilingId) { + const polygon = ceilingBoundaryDraft.polygon.map(toPoint2D) + nextEntry = { + ...entry, + polygon, + path: formatPolygonPath(polygon, entry.holes), + } + } + + const activeHoleDraft = + ceilingHoleBoundaryDraft && entry.ceiling.id === ceilingHoleBoundaryDraft.ceilingId + ? ceilingHoleBoundaryDraft + : ceilingHoleMoveDraft && entry.ceiling.id === ceilingHoleMoveDraft.ceilingId + ? ceilingHoleMoveDraft + : null + + if (activeHoleDraft) { + const draftHole = activeHoleDraft.polygon.map(toPoint2D) + const holes = nextEntry.holes.map((hole, index) => + index === activeHoleDraft.holeIndex ? draftHole : hole, + ) + + nextEntry = { + ...nextEntry, + holes, + path: formatPolygonPath(nextEntry.polygon, holes), + } + } + + return nextEntry + }) + }, [ceilingBoundaryDraft, ceilingHoleBoundaryDraft, ceilingHoleMoveDraft, ceilingPolygons]) const zonePolygons = useMemo( () => zones.flatMap((zone) => { @@ -5886,38 +7183,215 @@ export function FloorplanPanel() { ] }) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) - const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh) - const floorplanStairEntries = useMemo( - () => - floorplanStairs.flatMap((stair) => { - const displayStair = - movingNode?.type === 'stair' && movingNode.id === stair.id - ? (() => { - const live = useLiveTransforms.getState().get(stair.id) - const liveX = cursorPoint?.[0] ?? live?.position[0] ?? stair.position[0] - const liveZ = cursorPoint?.[1] ?? live?.position[2] ?? stair.position[2] - const liveRotation = live?.rotation ?? stair.rotation + const referenceFloorLevel = useMemo(() => { + if (!(showReferenceFloor && levelNode)) { + return null + } - return { - ...stair, - position: [liveX, stair.position[1], liveZ] as StairNode['position'], - rotation: liveRotation, - } - })() - : stair - const segments = (displayStair.children ?? []) - .map((childId) => levelDescendantNodeById.get(childId as AnyNodeId)) - .filter( - (node): node is StairSegmentNode => - node?.type === 'stair-segment' && node.visible !== false, - ) - const entry = buildSharedFloorplanStairEntry(displayStair, segments) - if (!entry) { - return [] - } - const hitPolygons = - (displayStair.stairType ?? 'straight') === 'straight' - ? entry.segments.map((segmentEntry) => segmentEntry.polygon) + const lowerLevels = floorplanLevels + .filter((floorLevel) => floorLevel.id !== levelNode.id && floorLevel.level < levelNode.level) + .sort((a, b) => b.level - a.level) + + return lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null + }, [floorplanLevels, levelNode, referenceFloorOffset, showReferenceFloor]) + const referenceFloorDescendants = useScene( + useShallow((state) => { + if (!referenceFloorLevel) { + return [] as AnyNode[] + } + + return collectLevelDescendants( + referenceFloorLevel, + state.nodes as Record, + ).filter((node) => node.visible !== false) + }), + ) + const referenceFloorData = useMemo(() => { + if (!referenceFloorLevel) { + return null + } + + const children = referenceFloorDescendants.filter( + (node) => node.parentId === referenceFloorLevel.id, + ) + const referenceWalls = children.filter((node): node is WallNode => node.type === 'wall') + const referenceFences = children.filter((node): node is FenceNode => node.type === 'fence') + const referenceSlabs = children.filter((node): node is SlabNode => node.type === 'slab') + const referenceCeilings = children.filter( + (node): node is CeilingNode => node.type === 'ceiling', + ) + const referenceDescendants = referenceFloorDescendants + const referenceDescendantById = new Map(referenceDescendants.map((node) => [node.id, node])) + + const referenceFloorplanWalls = referenceWalls.map(getFloorplanWall) + const referenceWallMiterData = calculateLevelMiters(referenceFloorplanWalls) + const referenceFloorplanWallById = new Map( + referenceFloorplanWalls.map((wall) => [wall.id, wall] as const), + ) + + const wallPolygons = referenceWalls.map((wall) => { + const floorplanWall = referenceFloorplanWallById.get(wall.id) ?? getFloorplanWall(wall) + const polygon = getWallPlanFootprint(floorplanWall, referenceWallMiterData) + + return { + points: formatPolygonPoints(polygon), + polygon, + wall, + } + }) + + const openingPolygons = referenceDescendants.flatMap((node) => { + if (!(node.type === 'door' || node.type === 'window')) { + return [] + } + + const wall = referenceFloorplanWallById.get(node.parentId as WallNode['id']) + if (!wall) { + return [] + } + + const polygon = getOpeningFootprint(wall, node) + return [ + { + opening: node, + points: formatPolygonPoints(polygon), + polygon, + }, + ] + }) + + const slabPolygons = referenceSlabs.flatMap((slab) => { + const polygon = toFloorplanPolygon(slab.polygon) + if (polygon.length < 3) { + return [] + } + + const holes = (slab.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3) + const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab)) + const visualHoles = holes + + return [ + { + slab, + polygon, + holes, + visualPolygon, + visualHoles, + path: formatPolygonPath(visualPolygon, visualHoles), + }, + ] + }) + + const ceilingPolygons = referenceCeilings.flatMap((ceiling) => { + const polygon = toFloorplanPolygon(ceiling.polygon) + if (polygon.length < 3) { + return [] + } + + const holes = (ceiling.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3) + + return [ + { + ceiling, + polygon, + holes, + path: formatPolygonPath(polygon, holes), + }, + ] + }) + + const fenceEntries = referenceFences.flatMap((fence) => { + const centerline = isCurvedWall(fence) + ? sampleWallCenterline(fence, 24) + : [ + { x: fence.start[0], y: fence.start[1] }, + { x: fence.end[0], y: fence.end[1] }, + ] + const path = buildSvgPolylinePath(centerline) + if (!path) { + return [] + } + + return [{ fence, centerline, markerFrames: [], path }] + }) + + const transformCache = new Map() + const itemEntries = referenceDescendants.flatMap((node) => { + if ( + !( + node.type === 'item' && + node.asset.category !== 'door' && + node.asset.category !== 'window' + ) + ) { + return [] + } + + const entry = buildFloorplanItemEntry(node, referenceDescendantById, transformCache) + if (!entry) { + return [] + } + + return [ + { + dimensionPolygon: entry.dimensionPolygon, + item: entry.item, + points: formatPolygonPoints(entry.polygon), + polygon: entry.polygon, + usesRealMesh: entry.usesRealMesh, + center: entry.center, + rotation: entry.rotation, + width: entry.width, + depth: entry.depth, + }, + ] + }) + + return { + ceilingPolygons, + fenceEntries, + itemEntries, + openingPolygons, + slabPolygons, + wallPolygons, + } + }, [referenceFloorDescendants, referenceFloorLevel]) + const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh) + const floorplanStairEntries = useMemo( + () => + floorplanStairs.flatMap((stair) => { + const displayStair = + movingNode?.type === 'stair' && movingNode.id === stair.id + ? (() => { + const live = useLiveTransforms.getState().get(stair.id) + const liveX = cursorPoint?.[0] ?? live?.position[0] ?? stair.position[0] + const liveZ = cursorPoint?.[1] ?? live?.position[2] ?? stair.position[2] + const liveRotation = live?.rotation ?? stair.rotation + + return { + ...stair, + position: [liveX, stair.position[1], liveZ] as StairNode['position'], + rotation: liveRotation, + } + })() + : stair + const segments = (displayStair.children ?? []) + .map((childId) => levelDescendantNodeById.get(childId as AnyNodeId)) + .filter( + (node): node is StairSegmentNode => + node?.type === 'stair-segment' && node.visible !== false, + ) + const entry = buildSharedFloorplanStairEntry(displayStair, segments) + if (!entry) { + return [] + } + const hitPolygons = + (displayStair.stairType ?? 'straight') === 'straight' + ? entry.segments.map((segmentEntry) => segmentEntry.polygon) : [getFloorplanCurvedStairHitPolygon(displayStair)] return [ @@ -6151,16 +7625,15 @@ export function FloorplanPanel() { `${selectedItemEntry.item.id}:clearance:${index}`, midpoint, bestHit.point, - formatMeasurement(bestHit.distance, unit), + formatMeasurement(bestHit.distance, unit, calibratedMetersPerUnit), { - offsetDistance: FLOORPLAN_MEASUREMENT_OFFSET, - offsetVector: tangent, + extensionOvershoot: 0, }, ) return overlay ? [overlay] : [] }) - }, [displayWallPolygons, selectedItemEntry, unit]) + }, [calibratedMetersPerUnit, displayWallPolygons, selectedItemEntry, unit]) const movingOpeningPlacementMeasurements = useMemo(() => { if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) { return [] as LinearMeasurementOverlay[] @@ -6258,7 +7731,7 @@ export function FloorplanPanel() { `${opening.id}:placement-left`, leftBoundaryPoint, openingFaceStart, - formatMeasurement(leftDistance, unit), + formatMeasurement(leftDistance, unit, calibratedMetersPerUnit), { offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, offsetVector: faceContext.outwardNormal, @@ -6282,7 +7755,7 @@ export function FloorplanPanel() { `${opening.id}:placement-right`, openingFaceEnd, rightBoundaryPoint, - formatMeasurement(rightDistance, unit), + formatMeasurement(rightDistance, unit, calibratedMetersPerUnit), { offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, offsetVector: faceContext.outwardNormal, @@ -6300,7 +7773,7 @@ export function FloorplanPanel() { } return overlays - }, [displayWallPolygons, movingNode, openingsPolygons, unit]) + }, [calibratedMetersPerUnit, displayWallPolygons, movingNode, openingsPolygons, unit]) const selectedWallEntry = useMemo(() => { if (selectedIds.length !== 1) { return null @@ -6331,6 +7804,10 @@ export function FloorplanPanel() { }, [floorplanRoofEntries, selectedIds]) const slabById = useMemo(() => new Map(slabs.map((slab) => [slab.id, slab] as const)), [slabs]) const zoneById = useMemo(() => new Map(zones.map((zone) => [zone.id, zone] as const)), [zones]) + const ceilingById = useMemo( + () => new Map(ceilings.map((ceiling) => [ceiling.id, ceiling] as const)), + [ceilings], + ) const selectedSlabEntry = useMemo(() => { if (selectedIds.length !== 1) { return null @@ -6343,8 +7820,8 @@ export function FloorplanPanel() { return null } - return ceilingPolygons.find(({ ceiling }) => ceiling.id === selectedIds[0]) ?? null - }, [ceilingPolygons, selectedIds]) + return displayCeilingPolygons.find(({ ceiling }) => ceiling.id === selectedIds[0]) ?? null + }, [displayCeilingPolygons, selectedIds]) const selectedZoneEntry = useMemo(() => { if (!selectedZoneId) { return null @@ -6490,7 +7967,11 @@ export function FloorplanPanel() { structureLayer !== 'zones' const canInteractElementFloorplanGeometry = isDeleteMode || canSelectElementFloorplanGeometry const canInteractFloorplanSlabs = isDeleteMode || canSelectElementFloorplanGeometry - const canInteractWithGuides = showGuides && canSelectElementFloorplanGeometry + const canInteractWithGuides = + showGuides && + canSelectElementFloorplanGeometry && + !referenceScaleDraft && + !pendingReferenceScale const canSelectFloorplanZones = mode === 'select' && floorplanSelectionTool === 'click' && @@ -6529,14 +8010,49 @@ export function FloorplanPanel() { !movingFenceEndpoint && isFloorplanItemContextActive const visibleSitePolygon = phase === 'site' ? displaySitePolygon : null + const selectedSlabEditingHoleIndex = + selectedSlabEntry && editingHole?.nodeId === selectedSlabEntry.slab.id + ? editingHole.holeIndex + : null + const selectedSlabEditingHole = + selectedSlabEditingHoleIndex !== null + ? (selectedSlabEntry?.holes[selectedSlabEditingHoleIndex] ?? null) + : null + const selectedCeilingEditingHoleIndex = + selectedCeilingEntry && editingHole?.nodeId === selectedCeilingEntry.ceiling.id + ? editingHole.holeIndex + : null + const selectedCeilingEditingHole = + selectedCeilingEditingHoleIndex !== null + ? (selectedCeilingEntry?.holes[selectedCeilingEditingHoleIndex] ?? null) + : null const shouldShowSiteBoundaryHandles = isSiteEditActive && visibleSitePolygon !== null - const shouldShowPersistentWallEndpointHandles = - mode === 'select' && !movingNode && !movingFenceEndpoint const shouldShowSlabBoundaryHandles = mode === 'select' && !movingNode && floorplanSelectionTool === 'click' && - selectedSlabEntry !== null + selectedSlabEntry !== null && + selectedSlabEditingHole === null + const shouldShowCeilingBoundaryHandles = + mode === 'select' && + !movingNode && + floorplanSelectionTool === 'click' && + selectedCeilingEntry !== null && + selectedCeilingEditingHole === null + const shouldShowSlabHoleBoundaryHandles = + mode === 'select' && + !movingNode && + floorplanSelectionTool === 'click' && + selectedSlabEntry !== null && + selectedSlabEditingHole !== null && + slabHoleMoveDraft === null + const shouldShowCeilingHoleBoundaryHandles = + mode === 'select' && + !movingNode && + floorplanSelectionTool === 'click' && + selectedCeilingEntry !== null && + selectedCeilingEditingHole !== null && + ceilingHoleMoveDraft === null const shouldShowZoneBoundaryHandles = canSelectFloorplanZones && selectedZoneEntry !== null const showZonePolygons = true // Zone polygons always visible (labels always clickable) const visibleZonePolygons = displayZonePolygons @@ -6581,11 +8097,7 @@ export function FloorplanPanel() { return displayWallPolygons.flatMap(({ wall }) => { const isSelected = selectedIdSet.has(wall.id) - const isVisible = - shouldShowPersistentWallEndpointHandles || - isWallBuildActive || - isSelected || - wallEndpointDraft?.wallId === wall.id + const isVisible = isSelected || wallEndpointDraft?.wallId === wall.id if (!isVisible) { return [] } @@ -6598,15 +8110,7 @@ export function FloorplanPanel() { isActive: wallEndpointDraft?.wallId === wall.id && wallEndpointDraft.endpoint === endpoint, })) }) - }, [ - displayWallPolygons, - isOpeningPlacementActive, - isWallBuildActive, - movingNode, - selectedIdSet, - shouldShowPersistentWallEndpointHandles, - wallEndpointDraft, - ]) + }, [displayWallPolygons, isOpeningPlacementActive, movingNode, selectedIdSet, wallEndpointDraft]) const fenceEndpointHandles = useMemo(() => { if ( isOpeningPlacementActive || @@ -6692,22 +8196,117 @@ export function FloorplanPanel() { return [] } - return selectedSlabEntry.polygon.map((point, vertexIndex) => ({ + const rawPolygon = selectedSlabEntry.polygon + + return getSlabHandlePolygon(selectedSlabEntry).map((point) => { + const vertexIndex = getClosestPolygonVertexIndex(point, rawPolygon) + + return { + nodeId: selectedSlabEntry.slab.id, + vertexIndex, + point: toWallPlanPoint(point), + isActive: + slabVertexDragState?.slabId === selectedSlabEntry.slab.id && + slabVertexDragState.vertexIndex === vertexIndex, + } + }) + }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) + const slabMidpointHandles = useMemo(() => { + if (!(shouldShowSlabBoundaryHandles && !slabVertexDragState)) { + return [] + } + + const handlePolygon = getSlabHandlePolygon(selectedSlabEntry) + + return handlePolygon.map((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + const midpoint = { + x: (point.x + (nextPoint?.x ?? point.x)) / 2, + y: (point.y + (nextPoint?.y ?? point.y)) / 2, + } + + return { + nodeId: selectedSlabEntry.slab.id, + edgeIndex, + point: [midpoint.x, midpoint.y] as WallPlanPoint, + } + }) + }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) + const ceilingVertexHandles = useMemo(() => { + if (!shouldShowCeilingBoundaryHandles) { + return [] + } + + return selectedCeilingEntry.polygon.map((point, vertexIndex) => ({ + nodeId: selectedCeilingEntry.ceiling.id, + vertexIndex, + point: toWallPlanPoint(point), + isActive: + ceilingVertexDragState?.ceilingId === selectedCeilingEntry.ceiling.id && + ceilingVertexDragState.vertexIndex === vertexIndex, + })) + }, [ceilingVertexDragState, selectedCeilingEntry, shouldShowCeilingBoundaryHandles]) + const ceilingMidpointHandles = useMemo(() => { + if (!(shouldShowCeilingBoundaryHandles && !ceilingVertexDragState)) { + return [] + } + + return selectedCeilingEntry.polygon.map((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + + return { + nodeId: selectedCeilingEntry.ceiling.id, + edgeIndex, + point: [ + (point.x + (nextPoint?.x ?? point.x)) / 2, + (point.y + (nextPoint?.y ?? point.y)) / 2, + ] as WallPlanPoint, + } + }) + }, [ceilingVertexDragState, selectedCeilingEntry, shouldShowCeilingBoundaryHandles]) + const slabHoleVertexHandles = useMemo(() => { + if ( + !( + shouldShowSlabHoleBoundaryHandles && + selectedSlabEntry && + selectedSlabEditingHole && + selectedSlabEditingHoleIndex !== null + ) + ) { + return [] + } + + return selectedSlabEditingHole.map((point, vertexIndex) => ({ nodeId: selectedSlabEntry.slab.id, vertexIndex, point: toWallPlanPoint(point), isActive: - slabVertexDragState?.slabId === selectedSlabEntry.slab.id && - slabVertexDragState.vertexIndex === vertexIndex, + slabHoleVertexDragState?.slabId === selectedSlabEntry.slab.id && + slabHoleVertexDragState.holeIndex === selectedSlabEditingHoleIndex && + slabHoleVertexDragState.vertexIndex === vertexIndex, })) - }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) - const slabMidpointHandles = useMemo(() => { - if (!(shouldShowSlabBoundaryHandles && !slabVertexDragState)) { + }, [ + selectedSlabEditingHole, + selectedSlabEditingHoleIndex, + selectedSlabEntry, + shouldShowSlabHoleBoundaryHandles, + slabHoleVertexDragState, + ]) + const slabHoleMidpointHandles = useMemo(() => { + if ( + !( + shouldShowSlabHoleBoundaryHandles && + selectedSlabEntry && + selectedSlabEditingHole && + !slabHoleVertexDragState + ) + ) { return [] } - return selectedSlabEntry.polygon.map((point, edgeIndex, polygon) => { + return selectedSlabEditingHole.map((point, edgeIndex, polygon) => { const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + return { nodeId: selectedSlabEntry.slab.id, edgeIndex, @@ -6717,7 +8316,70 @@ export function FloorplanPanel() { ] as WallPlanPoint, } }) - }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) + }, [ + selectedSlabEditingHole, + selectedSlabEntry, + shouldShowSlabHoleBoundaryHandles, + slabHoleVertexDragState, + ]) + const ceilingHoleVertexHandles = useMemo(() => { + if ( + !( + shouldShowCeilingHoleBoundaryHandles && + selectedCeilingEntry && + selectedCeilingEditingHole && + selectedCeilingEditingHoleIndex !== null + ) + ) { + return [] + } + + return selectedCeilingEditingHole.map((point, vertexIndex) => ({ + nodeId: selectedCeilingEntry.ceiling.id, + vertexIndex, + point: toWallPlanPoint(point), + isActive: + ceilingHoleVertexDragState?.ceilingId === selectedCeilingEntry.ceiling.id && + ceilingHoleVertexDragState.holeIndex === selectedCeilingEditingHoleIndex && + ceilingHoleVertexDragState.vertexIndex === vertexIndex, + })) + }, [ + ceilingHoleVertexDragState, + selectedCeilingEditingHole, + selectedCeilingEditingHoleIndex, + selectedCeilingEntry, + shouldShowCeilingHoleBoundaryHandles, + ]) + const ceilingHoleMidpointHandles = useMemo(() => { + if ( + !( + shouldShowCeilingHoleBoundaryHandles && + selectedCeilingEntry && + selectedCeilingEditingHole && + !ceilingHoleVertexDragState + ) + ) { + return [] + } + + return selectedCeilingEditingHole.map((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + + return { + nodeId: selectedCeilingEntry.ceiling.id, + edgeIndex, + point: [ + (point.x + (nextPoint?.x ?? point.x)) / 2, + (point.y + (nextPoint?.y ?? point.y)) / 2, + ] as WallPlanPoint, + } + }) + }, [ + ceilingHoleVertexDragState, + selectedCeilingEditingHole, + selectedCeilingEntry, + shouldShowCeilingHoleBoundaryHandles, + ]) const siteVertexHandles = useMemo(() => { if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon)) { return [] @@ -6884,7 +8546,7 @@ export function FloorplanPanel() { const fittedViewport = useMemo(() => { const allPoints = [ ...(visibleSitePolygon ? visibleSitePolygon.polygon : []), - ...ceilingPolygons.flatMap((entry) => entry.polygon), + ...displayCeilingPolygons.flatMap((entry) => entry.polygon), ...displaySlabPolygons.flatMap((entry) => entry.polygon), ...floorplanFenceEntries.flatMap((entry) => entry.centerline), ...floorplanItemEntries.flatMap((entry) => entry.polygon), @@ -6931,7 +8593,7 @@ export function FloorplanPanel() { width, } }, [ - ceilingPolygons, + displayCeilingPolygons, displaySlabPolygons, floorplanFenceEntries, floorplanItemEntries, @@ -7010,6 +8672,11 @@ export function FloorplanPanel() { movingFenceEndpoint != null || curvingWall != null || curvingFence != null || + ceilingVertexDragState != null || + ceilingHoleMoveDraft != null || + ceilingHoleVertexDragState != null || + slabHoleMoveDraft != null || + slabHoleVertexDragState != null || slabVertexDragState != null || siteVertexDragState != null || zoneVertexDragState != null || @@ -7029,7 +8696,12 @@ export function FloorplanPanel() { levelId, movingFenceEndpoint, movingNode, + ceilingVertexDragState, + ceilingHoleMoveDraft, + ceilingHoleVertexDragState, siteVertexDragState, + slabHoleMoveDraft, + slabHoleVertexDragState, slabVertexDragState, zoneVertexDragState, ]) @@ -7065,7 +8737,11 @@ export function FloorplanPanel() { [floorplanWorldUnitsPerPixel], ) const wallSelectionHatchStrokeWidth = useMemo( - () => Math.max(floorplanWorldUnitsPerPixel, 0.0001), + () => Math.max(floorplanWorldUnitsPerPixel * 0.25, 0.0001), + [floorplanWorldUnitsPerPixel], + ) + const slabSelectionHatchStrokeWidth = useMemo( + () => Math.max(floorplanWorldUnitsPerPixel * 0.55, 0.0001), [floorplanWorldUnitsPerPixel], ) const selectedOpeningActionMenuPosition = useMemo( @@ -7082,21 +8758,37 @@ export function FloorplanPanel() { : null, [selectedItemEntry, surfaceSize, viewBox], ) - const selectedSlabActionMenuPosition = useMemo( - () => - selectedSlabEntry - ? getFloorplanActionMenuPosition(selectedSlabEntry.polygon, viewBox, surfaceSize) - : null, - [selectedSlabEntry, surfaceSize, viewBox], - ) - const selectedCeilingActionMenuPosition = useMemo( - () => - selectedCeilingEntry - ? getFloorplanActionMenuPosition(selectedCeilingEntry.polygon, viewBox, surfaceSize) - : null, - [selectedCeilingEntry, surfaceSize, viewBox], - ) - const selectedWallActionMenuPosition = useMemo( + const selectedSlabActionMenuPosition = useMemo(() => { + if (slabHoleMoveDraft) { + return null + } + + if (selectedSlabEditingHole) { + return getFloorplanActionMenuPosition(selectedSlabEditingHole, viewBox, surfaceSize) + } + + return selectedSlabEntry + ? getFloorplanActionMenuPosition( + getSlabHandlePolygon(selectedSlabEntry), + viewBox, + surfaceSize, + ) + : null + }, [selectedSlabEditingHole, selectedSlabEntry, slabHoleMoveDraft, surfaceSize, viewBox]) + const selectedCeilingActionMenuPosition = useMemo(() => { + if (ceilingHoleMoveDraft) { + return null + } + + if (selectedCeilingEditingHole) { + return getFloorplanActionMenuPosition(selectedCeilingEditingHole, viewBox, surfaceSize) + } + + return selectedCeilingEntry + ? getFloorplanActionMenuPosition(selectedCeilingEntry.polygon, viewBox, surfaceSize) + : null + }, [ceilingHoleMoveDraft, selectedCeilingEditingHole, selectedCeilingEntry, surfaceSize, viewBox]) + const selectedWallActionMenuPosition = useMemo( () => selectedWallEntry ? getFloorplanActionMenuPosition(selectedWallEntry.polygon, viewBox, surfaceSize) @@ -7253,37 +8945,51 @@ export function FloorplanPanel() { theme === 'dark' ? { surface: '#0a0e1b', - minorGrid: '#475569', - majorGrid: '#94a3b8', - minorGridOpacity: 0.7, - majorGridOpacity: 0.9, - slabFill: 'rgba(100, 116, 139, 0.16)', - slabStroke: 'rgba(148, 163, 184, 0.45)', - selectedSlabFill: 'rgba(167, 139, 250, 0.14)', - selectedSlabStroke: '#a78bfa', - ceilingFill: 'rgba(56, 189, 248, 0.12)', - ceilingStroke: '#38bdf8', + minorGrid: '#334155', + majorGrid: '#64748b', + minorGridOpacity: 0.62, + majorGridOpacity: 0.86, + slabFill: 'rgba(51, 65, 85, 0.48)', + slabStroke: 'rgba(203, 213, 225, 0.82)', + selectedSlabFill: 'rgba(59, 130, 246, 0.14)', + selectedSlabStroke: '#93c5fd', + ceilingFill: 'rgba(15, 23, 42, 0.18)', + ceilingStroke: 'rgba(226, 232, 240, 0.74)', selectedCeilingFill: 'rgba(59, 130, 246, 0.16)', - selectedCeilingStroke: '#2563eb', - wallFill: '#ffffff', - wallStroke: 'rgba(31, 41, 55, 0.9)', - wallInnerStroke: 'rgba(51, 65, 85, 0.72)', - wallShadow: 'rgba(15, 23, 42, 0.12)', - wallHoverStroke: '#60a5fa', + selectedCeilingStroke: '#93c5fd', + wallFill: '#d8dee9', + wallStroke: '#f8fafc', + wallInnerStroke: 'rgba(148, 163, 184, 0.82)', + wallShadow: 'rgba(0, 0, 0, 0.42)', + wallHoverStroke: '#7dd3fc', deleteFill: '#f87171', deleteStroke: '#ef4444', deleteWallFill: '#ef4444', deleteWallHoverStroke: '#fca5a5', - selectedFill: '#fafafa', - selectedStroke: '#3b82f6', + selectedFill: '#eff6ff', + selectedStroke: '#60a5fa', draftFill: '#818cf8', draftStroke: '#c7d2fe', - measurementStroke: '#cbd5e1', + measurementStroke: '#e2e8f0', cursor: '#818cf8', editCursor: '#8381ed', anchor: '#818cf8', openingFill: '#0a0e1b', - openingStroke: '#fafafa', + openingStroke: '#f8fafc', + roofFill: 'rgba(56, 189, 248, 0.16)', + roofActiveFill: 'rgba(56, 189, 248, 0.24)', + roofSelectedFill: 'rgba(147, 197, 253, 0.28)', + roofStroke: 'rgba(125, 211, 252, 0.82)', + roofActiveStroke: '#38bdf8', + roofSelectedStroke: '#93c5fd', + roofRidgeStroke: 'rgba(186, 230, 253, 0.84)', + roofSelectedRidgeStroke: '#eff6ff', + stairFill: 'rgba(226, 232, 240, 0.12)', + stairSelectedFill: 'rgba(96, 165, 250, 0.18)', + stairStroke: '#e2e8f0', + stairAccent: '#f8fafc', + stairTread: 'rgba(226, 232, 240, 0.68)', + stairSelectedTread: 'rgba(147, 197, 253, 0.86)', endpointHandleFill: '#fff7ed', endpointHandleStroke: '#c2410c', endpointHandleHoverStroke: '#fb923c', @@ -7299,15 +9005,15 @@ export function FloorplanPanel() { majorGrid: '#475569', minorGridOpacity: 0.7, majorGridOpacity: 0.9, - slabFill: 'rgba(148, 163, 184, 0.22)', - slabStroke: 'rgba(100, 116, 139, 0.55)', - selectedSlabFill: 'rgba(167, 139, 250, 0.14)', - selectedSlabStroke: '#a78bfa', - ceilingFill: 'rgba(14, 165, 233, 0.12)', - ceilingStroke: '#0ea5e9', + slabFill: '#f6f6f6', + slabStroke: '#9e9e9e', + selectedSlabFill: 'rgba(59, 130, 246, 0.14)', + selectedSlabStroke: '#3b82f6', + ceilingFill: '#f6f6f6', + ceilingStroke: '#9e9e9e', selectedCeilingFill: 'rgba(59, 130, 246, 0.16)', selectedCeilingStroke: '#2563eb', - wallFill: '#ffffff', + wallFill: '#1f2937', wallStroke: 'rgba(31, 41, 55, 0.9)', wallInnerStroke: 'rgba(71, 85, 105, 0.58)', wallShadow: 'rgba(15, 23, 42, 0.1)', @@ -7326,6 +9032,20 @@ export function FloorplanPanel() { anchor: '#4338ca', openingFill: '#ffffff', openingStroke: '#171717', + roofFill: 'rgba(14, 165, 233, 0.08)', + roofActiveFill: 'rgba(14, 165, 233, 0.14)', + roofSelectedFill: 'rgba(14, 165, 233, 0.2)', + roofStroke: 'rgba(14, 165, 233, 0.65)', + roofActiveStroke: '#0ea5e9', + roofSelectedStroke: '#0369a1', + roofRidgeStroke: 'rgba(3, 105, 161, 0.75)', + roofSelectedRidgeStroke: '#0f172a', + stairFill: 'rgba(255, 255, 255, 0.02)', + stairSelectedFill: 'rgba(59, 130, 246, 0.08)', + stairStroke: 'rgba(23, 23, 23, 0.88)', + stairAccent: 'rgba(23, 23, 23, 0.96)', + stairTread: 'rgba(38, 38, 38, 0.62)', + stairSelectedTread: 'rgba(37, 99, 235, 0.78)', endpointHandleFill: '#fff7ed', endpointHandleStroke: '#c2410c', endpointHandleHoverStroke: '#fb923c', @@ -7338,6 +9058,7 @@ export function FloorplanPanel() { [theme], ) const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme]) + const slabSelectionHatchId = useMemo(() => `floorplan-slab-selection-hatch-${theme}`, [theme]) const gridSteps = useMemo( () => getVisibleGridSteps(viewBox.width, surfaceSize.width), [surfaceSize.width, viewBox.width], @@ -7368,6 +9089,156 @@ export function FloorplanPanel() { ), [gridSteps.majorStep, viewBox], ) + const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) + + useEffect(() => { + setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') + }, [unit]) + + const startReferenceScaleForGuide = useCallback( + (guideId: GuideNode['id']) => { + const guide = guideById.get(guideId) + if (!guide) { + return + } + + setReferenceScaleDraft({ + guideId: guide.id, + start: null, + cursor: null, + }) + setPendingReferenceScale(null) + setMode('select') + setFloorplanSelectionTool('click') + setShowGuides(true) + setSelection({ selectedIds: [], zoneId: null }) + setSelectedReferenceId(guide.id) + }, + [ + guideById, + setFloorplanSelectionTool, + setMode, + setSelectedReferenceId, + setSelection, + setShowGuides, + ], + ) + + useEffect(() => { + const handleSetReferenceScale = (payload: { guideId?: GuideNode['id'] }) => { + if (payload.guideId) { + startReferenceScaleForGuide(payload.guideId) + } + } + + emitter.on('guide:set-reference-scale', handleSetReferenceScale) + return () => { + emitter.off('guide:set-reference-scale', handleSetReferenceScale) + } + }, [startReferenceScaleForGuide]) + + useEffect(() => { + const handleCancel = () => { + setReferenceScaleDraft(null) + setPendingReferenceScale(null) + } + + emitter.on('guide:cancel-reference-scale', handleCancel) + return () => { + emitter.off('guide:cancel-reference-scale', handleCancel) + } + }, []) + + useEffect(() => { + const handleDeleted = (payload: { guideId?: GuideNode['id'] }) => { + if (!payload.guideId) { + return + } + + setReferenceScaleDraft((current) => (current?.guideId === payload.guideId ? null : current)) + setPendingReferenceScale((current) => (current?.guideId === payload.guideId ? null : current)) + clearGuideUi(payload.guideId) + } + + emitter.on('guide:deleted', handleDeleted) + return () => { + emitter.off('guide:deleted', handleDeleted) + } + }, [clearGuideUi]) + + const handleReferenceScaleConfirm = useCallback(() => { + if (!pendingReferenceScale) { + return + } + + const guide = guideById.get(pendingReferenceScale.guideId) + if (!guide) { + setPendingReferenceScale(null) + return + } + + const displayLength = Number(referenceScaleValue) + if (!(displayLength > 0)) { + return + } + + const realLengthMeters = convertReferenceLengthToMeters(displayLength, referenceScaleUnit) + const requestedScaleFactor = realLengthMeters / pendingReferenceScale.measuredLengthUnits + const currentGuideScale = guide.scale > 0 ? guide.scale : 1 + const nextGuideScale = Math.max( + currentGuideScale * requestedScaleFactor, + FLOORPLAN_GUIDE_MIN_SCALE, + ) + const appliedScaleFactor = nextGuideScale / currentGuideScale + const scaledEnd: WallPlanPoint = [ + pendingReferenceScale.start[0] + + (pendingReferenceScale.end[0] - pendingReferenceScale.start[0]) * appliedScaleFactor, + pendingReferenceScale.start[1] + + (pendingReferenceScale.end[1] - pendingReferenceScale.start[1]) * appliedScaleFactor, + ] + const scaledMeasuredLengthUnits = Math.hypot( + scaledEnd[0] - pendingReferenceScale.start[0], + scaledEnd[1] - pendingReferenceScale.start[1], + ) + const nextGuidePosition: GuideNode['position'] = [ + pendingReferenceScale.start[0] + + (guide.position[0] - pendingReferenceScale.start[0]) * appliedScaleFactor, + guide.position[1], + pendingReferenceScale.start[1] + + (guide.position[2] - pendingReferenceScale.start[1]) * appliedScaleFactor, + ] + const metersPerUnit = + scaledMeasuredLengthUnits > 0 ? realLengthMeters / scaledMeasuredLengthUnits : 1 + + updateNode( + pendingReferenceScale.guideId as AnyNodeId, + { + position: nextGuidePosition, + scale: nextGuideScale, + scaleReference: { + start: pendingReferenceScale.start, + end: scaledEnd, + realLengthMeters, + measuredLengthUnits: scaledMeasuredLengthUnits, + metersPerUnit, + label: formatReferenceScaleLabel(displayLength, referenceScaleUnit), + }, + } as Partial, + ) + setGuideLocked(pendingReferenceScale.guideId, true) + setGuideScaleReferenceVisible(pendingReferenceScale.guideId, true) + setSelectedReferenceId(pendingReferenceScale.guideId) + setPendingReferenceScale(null) + }, [ + guideById, + pendingReferenceScale, + referenceScaleUnit, + referenceScaleValue, + setGuideLocked, + setGuideScaleReferenceVisible, + setSelectedReferenceId, + updateNode, + ]) const getSvgPointFromClientPoint = useCallback( (clientX: number, clientY: number): SvgPoint | null => { @@ -7412,6 +9283,18 @@ export function FloorplanPanel() { slabBoundaryDraftRef.current = slabBoundaryDraft }, [slabBoundaryDraft]) + useEffect(() => { + slabHoleBoundaryDraftRef.current = slabHoleBoundaryDraft + }, [slabHoleBoundaryDraft]) + + useEffect(() => { + ceilingBoundaryDraftRef.current = ceilingBoundaryDraft + }, [ceilingBoundaryDraft]) + + useEffect(() => { + ceilingHoleBoundaryDraftRef.current = ceilingHoleBoundaryDraft + }, [ceilingHoleBoundaryDraft]) + useEffect(() => { zoneBoundaryDraftRef.current = zoneBoundaryDraft }, [zoneBoundaryDraft]) @@ -7649,6 +9532,25 @@ export function FloorplanPanel() { setSlabVertexDragState(null) setSlabBoundaryDraft(null) setHoveredSlabHandleId(null) + document.body.style.cursor = '' + }, []) + const clearSlabHoleBoundaryInteraction = useCallback(() => { + setSlabHoleVertexDragState(null) + setSlabHoleBoundaryDraft(null) + setHoveredSlabHandleId(null) + document.body.style.cursor = '' + }, []) + const clearCeilingBoundaryInteraction = useCallback(() => { + setCeilingVertexDragState(null) + setCeilingBoundaryDraft(null) + setHoveredCeilingHandleId(null) + document.body.style.cursor = '' + }, []) + const clearCeilingHoleBoundaryInteraction = useCallback(() => { + setCeilingHoleVertexDragState(null) + setCeilingHoleBoundaryDraft(null) + setHoveredCeilingHandleId(null) + document.body.style.cursor = '' }, []) const clearZoneBoundaryInteraction = useCallback(() => { setZoneVertexDragState(null) @@ -7667,9 +9569,11 @@ export function FloorplanPanel() { clearWallCurveDrag() clearSiteBoundaryInteraction() clearSlabBoundaryInteraction() + clearCeilingBoundaryInteraction() clearZoneBoundaryInteraction() setCursorPoint(null) }, [ + clearCeilingBoundaryInteraction, clearFencePlacementDraft, clearCeilingPlacementDraft, clearRoofPlacementDraft, @@ -8152,6 +10056,7 @@ export function FloorplanPanel() { number, ], scale: nextDraft.scale, + scaleReference: transformGuideScaleReference(guide, nextDraft), }) } @@ -8330,6 +10235,30 @@ export function FloorplanPanel() { clearSlabBoundaryInteraction() }, [clearSlabBoundaryInteraction, shouldShowSlabBoundaryHandles]) + useEffect(() => { + if (shouldShowCeilingBoundaryHandles) { + return + } + + clearCeilingBoundaryInteraction() + }, [clearCeilingBoundaryInteraction, shouldShowCeilingBoundaryHandles]) + + useEffect(() => { + if (shouldShowSlabHoleBoundaryHandles) { + return + } + + clearSlabHoleBoundaryInteraction() + }, [clearSlabHoleBoundaryInteraction, shouldShowSlabHoleBoundaryHandles]) + + useEffect(() => { + if (shouldShowCeilingHoleBoundaryHandles) { + return + } + + clearCeilingHoleBoundaryInteraction() + }, [clearCeilingHoleBoundaryInteraction, shouldShowCeilingHoleBoundaryHandles]) + useEffect(() => { if (shouldShowZoneBoundaryHandles) { return @@ -8460,8 +10389,12 @@ export function FloorplanPanel() { return } - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] - setCursorPoint(snappedPoint) + const snappedHandlePoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + setCursorPoint(snappedHandlePoint) + const snappedPoint: WallPlanPoint = [ + snappedHandlePoint[0] - dragState.visualOffset.x, + snappedHandlePoint[1] - dragState.visualOffset.y, + ] setSlabBoundaryDraft((currentDraft) => { if (!currentDraft || currentDraft.slabId !== dragState.slabId) { @@ -8540,7 +10473,7 @@ export function FloorplanPanel() { ]) useEffect(() => { - const dragState = zoneVertexDragState + const dragState = ceilingVertexDragState if (!dragState) { return } @@ -8560,8 +10493,8 @@ export function FloorplanPanel() { const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] setCursorPoint(snappedPoint) - setZoneBoundaryDraft((currentDraft) => { - if (!currentDraft || currentDraft.zoneId !== dragState.zoneId) { + setCeilingBoundaryDraft((currentDraft) => { + if (!currentDraft || currentDraft.ceilingId !== dragState.ceilingId) { return currentDraft } @@ -8582,14 +10515,14 @@ export function FloorplanPanel() { }) } - const commitZoneVertexDrag = (event: PointerEvent) => { + const commitCeilingVertexDrag = (event: PointerEvent) => { if (event.pointerId !== dragState.pointerId) { return } - const draft = zoneBoundaryDraftRef.current - const zone = zoneById.get(dragState.zoneId) - if (draft && zone && !polygonsEqual(draft.polygon, zone.polygon)) { + const draft = ceilingBoundaryDraftRef.current + const ceiling = ceilingById.get(dragState.ceilingId) + if (draft && ceiling && !polygonsEqual(draft.polygon, ceiling.polygon)) { const suppressClick = (clickEvent: MouseEvent) => { clickEvent.stopImmediatePropagation() clickEvent.preventDefault() @@ -8600,121 +10533,586 @@ export function FloorplanPanel() { window.removeEventListener('click', suppressClick, true) }) - updateNode(draft.zoneId, { + updateNode(draft.ceilingId, { polygon: draft.polygon, }) sfxEmitter.emit('sfx:structure-build') } - clearZoneBoundaryInteraction() + clearCeilingBoundaryInteraction() setCursorPoint(null) } - const cancelZoneVertexDrag = (event: PointerEvent) => { + const cancelCeilingVertexDrag = (event: PointerEvent) => { if (event.pointerId !== dragState.pointerId) { return } - clearZoneBoundaryInteraction() + clearCeilingBoundaryInteraction() setCursorPoint(null) } window.addEventListener('pointermove', handleWindowPointerMove) - window.addEventListener('pointerup', commitZoneVertexDrag) - window.addEventListener('pointercancel', cancelZoneVertexDrag) + window.addEventListener('pointerup', commitCeilingVertexDrag) + window.addEventListener('pointercancel', cancelCeilingVertexDrag) return () => { window.removeEventListener('pointermove', handleWindowPointerMove) - window.removeEventListener('pointerup', commitZoneVertexDrag) - window.removeEventListener('pointercancel', cancelZoneVertexDrag) + window.removeEventListener('pointerup', commitCeilingVertexDrag) + window.removeEventListener('pointercancel', cancelCeilingVertexDrag) } }, [ - clearZoneBoundaryInteraction, + ceilingById, + ceilingVertexDragState, + clearCeilingBoundaryInteraction, getPlanPointFromClientPoint, updateNode, - zoneById, - zoneVertexDragState, ]) useEffect(() => { - return () => { - setFloorplanHovered(false) - } - }, [setFloorplanHovered]) - - const handlePointerDown = useCallback((event: ReactPointerEvent) => { - if (event.button !== 2) { + const dragState = slabHoleVertexDragState + if (!dragState) { return } - event.preventDefault() - event.stopPropagation() + const handleWindowPointerMove = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } - panStateRef.current = { - pointerId: event.pointerId, - clientX: event.clientX, - clientY: event.clientY, - } - setIsPanning(true) + event.preventDefault() - event.currentTarget.setPointerCapture(event.pointerId) - }, []) + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } - const endPanning = useCallback((event?: ReactPointerEvent) => { - if (event && panStateRef.current && event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId) + const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + setCursorPoint(snappedPoint) + + setSlabHoleBoundaryDraft((currentDraft) => { + if ( + !currentDraft || + currentDraft.slabId !== dragState.slabId || + currentDraft.holeIndex !== dragState.holeIndex + ) { + return currentDraft + } + + const currentPoint = currentDraft.polygon[dragState.vertexIndex] + if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { + return currentDraft + } + + sfxEmitter.emit('sfx:grid-snap') + + const nextPolygon = [...currentDraft.polygon] + nextPolygon[dragState.vertexIndex] = snappedPoint + + return { + ...currentDraft, + polygon: nextPolygon, + } + }) } - panStateRef.current = null - setIsPanning(false) - }, []) + const commitSlabHoleVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } - const hoveredWallIdRef = useRef(null) - const floorplanGridLocalY = useMemo(() => { - if (movingNode?.type === 'item') { - return movingNode.position[1] + const draft = slabHoleBoundaryDraftRef.current + const slab = slabById.get(dragState.slabId) + const currentHole = slab?.holes?.[dragState.holeIndex] + if (draft && slab && currentHole && !polygonsEqual(draft.polygon, currentHole)) { + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + const nextHoles = [...(slab.holes ?? [])] + nextHoles[draft.holeIndex] = draft.polygon + updateNode(draft.slabId, { + holes: nextHoles, + }) + sfxEmitter.emit('sfx:structure-build') + } + + clearSlabHoleBoundaryInteraction() + setCursorPoint(null) } - if (levelId) { - return sceneRegistry.nodes.get(levelId as AnyNodeId)?.position.y ?? 0 + const cancelSlabHoleVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + clearSlabHoleBoundaryInteraction() + setCursorPoint(null) } - return 0 - }, [levelId, movingNode]) - const floorplanGridWorldY = buildingPosition[1] + floorplanGridLocalY - const emitFloorplanWallLeave = useCallback((wallId: string | null) => { - if (!wallId) { - return + window.addEventListener('pointermove', handleWindowPointerMove) + window.addEventListener('pointerup', commitSlabHoleVertexDrag) + window.addEventListener('pointercancel', cancelSlabHoleVertexDrag) + + return () => { + window.removeEventListener('pointermove', handleWindowPointerMove) + window.removeEventListener('pointerup', commitSlabHoleVertexDrag) + window.removeEventListener('pointercancel', cancelSlabHoleVertexDrag) } + }, [ + clearSlabHoleBoundaryInteraction, + getPlanPointFromClientPoint, + slabById, + slabHoleVertexDragState, + updateNode, + ]) - const wallNode = useScene.getState().nodes[wallId as AnyNodeId] - if (!wallNode || wallNode.type !== 'wall') { + useEffect(() => { + const moveDraft = slabHoleMoveDraft + if (!moveDraft) { return } - emitter.emit('wall:leave', { - node: wallNode, - position: [0, 0, 0], - localPosition: [0, 0, 0], - stopPropagation: () => {}, - } as any) - }, []) - const emitFloorplanGridEvent = useCallback( - ( - eventType: 'move' | 'click' | 'double-click', - planPoint: WallPlanPoint, - nativeEvent: ReactMouseEvent | ReactPointerEvent, - ) => { - const snappedPoint = getSnappedFloorplanPoint(planPoint) - const cos = Math.cos(buildingRotationY) - const sin = Math.sin(buildingRotationY) - const worldX = buildingPosition[0] + snappedPoint[0] * cos - snappedPoint[1] * sin - const worldZ = buildingPosition[2] + snappedPoint[0] * sin + snappedPoint[1] * cos + const updateMoveDraft = (clientX: number, clientY: number) => { + const planPoint = getPlanPointFromClientPoint(clientX, clientY) + if (!planPoint) { + return + } - emitter.emit(`grid:${eventType}` as any, { - nativeEvent: nativeEvent.nativeEvent as any, - position: [worldX, floorplanGridWorldY, worldZ], - localPosition: [snappedPoint[0], floorplanGridLocalY, snappedPoint[1]], + const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const deltaX = snappedPoint[0] - moveDraft.startPlanPoint[0] + const deltaY = snappedPoint[1] - moveDraft.startPlanPoint[1] + const nextPolygon = moveDraft.originalPolygon.map( + ([x, y]) => [x + deltaX, y + deltaY] as WallPlanPoint, + ) + + setCursorPoint(snappedPoint) + setSlabHoleMoveDraft((currentDraft) => + currentDraft && + currentDraft.slabId === moveDraft.slabId && + currentDraft.holeIndex === moveDraft.holeIndex + ? { + ...currentDraft, + polygon: nextPolygon, + } + : currentDraft, + ) + } + + const commitSlabHoleMove = (event: PointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + + const slab = slabById.get(moveDraft.slabId) + const currentHole = slab?.holes?.[moveDraft.holeIndex] + if (slab && currentHole && !polygonsEqual(moveDraft.polygon, currentHole)) { + const nextHoles = [...(slab.holes ?? [])] + nextHoles[moveDraft.holeIndex] = moveDraft.polygon + updateNode(moveDraft.slabId, { + holes: nextHoles, + }) + sfxEmitter.emit('sfx:structure-build') + } + + setSlabHoleMoveDraft(null) + setCursorPoint(null) + } + + const cancelSlabHoleMove = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return + } + + event.preventDefault() + setSlabHoleMoveDraft(null) + setCursorPoint(null) + } + + const handleWindowPointerMove = (event: PointerEvent) => { + updateMoveDraft(event.clientX, event.clientY) + } + + window.addEventListener('pointermove', handleWindowPointerMove) + window.addEventListener('pointerdown', commitSlabHoleMove, true) + window.addEventListener('keydown', cancelSlabHoleMove) + + return () => { + window.removeEventListener('pointermove', handleWindowPointerMove) + window.removeEventListener('pointerdown', commitSlabHoleMove, true) + window.removeEventListener('keydown', cancelSlabHoleMove) + } + }, [getPlanPointFromClientPoint, slabById, slabHoleMoveDraft, updateNode]) + + useEffect(() => { + const dragState = ceilingHoleVertexDragState + if (!dragState) { + return + } + + const handleWindowPointerMove = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + event.preventDefault() + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } + + const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + setCursorPoint(snappedPoint) + + setCeilingHoleBoundaryDraft((currentDraft) => { + if ( + !currentDraft || + currentDraft.ceilingId !== dragState.ceilingId || + currentDraft.holeIndex !== dragState.holeIndex + ) { + return currentDraft + } + + const currentPoint = currentDraft.polygon[dragState.vertexIndex] + if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { + return currentDraft + } + + sfxEmitter.emit('sfx:grid-snap') + + const nextPolygon = [...currentDraft.polygon] + nextPolygon[dragState.vertexIndex] = snappedPoint + + return { + ...currentDraft, + polygon: nextPolygon, + } + }) + } + + const commitCeilingHoleVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + const draft = ceilingHoleBoundaryDraftRef.current + const ceiling = ceilingById.get(dragState.ceilingId) + const currentHole = ceiling?.holes?.[dragState.holeIndex] + if (draft && ceiling && currentHole && !polygonsEqual(draft.polygon, currentHole)) { + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + const nextHoles = [...(ceiling.holes ?? [])] + nextHoles[draft.holeIndex] = draft.polygon + updateNode(draft.ceilingId, { + holes: nextHoles, + }) + sfxEmitter.emit('sfx:structure-build') + } + + clearCeilingHoleBoundaryInteraction() + setCursorPoint(null) + } + + const cancelCeilingHoleVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + clearCeilingHoleBoundaryInteraction() + setCursorPoint(null) + } + + window.addEventListener('pointermove', handleWindowPointerMove) + window.addEventListener('pointerup', commitCeilingHoleVertexDrag) + window.addEventListener('pointercancel', cancelCeilingHoleVertexDrag) + + return () => { + window.removeEventListener('pointermove', handleWindowPointerMove) + window.removeEventListener('pointerup', commitCeilingHoleVertexDrag) + window.removeEventListener('pointercancel', cancelCeilingHoleVertexDrag) + } + }, [ + ceilingById, + ceilingHoleVertexDragState, + clearCeilingHoleBoundaryInteraction, + getPlanPointFromClientPoint, + updateNode, + ]) + + useEffect(() => { + const moveDraft = ceilingHoleMoveDraft + if (!moveDraft) { + return + } + + const updateMoveDraft = (clientX: number, clientY: number) => { + const planPoint = getPlanPointFromClientPoint(clientX, clientY) + if (!planPoint) { + return + } + + const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const deltaX = snappedPoint[0] - moveDraft.startPlanPoint[0] + const deltaY = snappedPoint[1] - moveDraft.startPlanPoint[1] + const nextPolygon = moveDraft.originalPolygon.map( + ([x, y]) => [x + deltaX, y + deltaY] as WallPlanPoint, + ) + + setCursorPoint(snappedPoint) + setCeilingHoleMoveDraft((currentDraft) => + currentDraft && + currentDraft.ceilingId === moveDraft.ceilingId && + currentDraft.holeIndex === moveDraft.holeIndex + ? { + ...currentDraft, + polygon: nextPolygon, + } + : currentDraft, + ) + } + + const commitCeilingHoleMove = (event: PointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + + const ceiling = ceilingById.get(moveDraft.ceilingId) + const currentHole = ceiling?.holes?.[moveDraft.holeIndex] + if (ceiling && currentHole && !polygonsEqual(moveDraft.polygon, currentHole)) { + const nextHoles = [...(ceiling.holes ?? [])] + nextHoles[moveDraft.holeIndex] = moveDraft.polygon + updateNode(moveDraft.ceilingId, { + holes: nextHoles, + }) + sfxEmitter.emit('sfx:structure-build') + } + + setCeilingHoleMoveDraft(null) + setCursorPoint(null) + } + + const cancelCeilingHoleMove = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return + } + + event.preventDefault() + setCeilingHoleMoveDraft(null) + setCursorPoint(null) + } + + const handleWindowPointerMove = (event: PointerEvent) => { + updateMoveDraft(event.clientX, event.clientY) + } + + window.addEventListener('pointermove', handleWindowPointerMove) + window.addEventListener('pointerdown', commitCeilingHoleMove, true) + window.addEventListener('keydown', cancelCeilingHoleMove) + + return () => { + window.removeEventListener('pointermove', handleWindowPointerMove) + window.removeEventListener('pointerdown', commitCeilingHoleMove, true) + window.removeEventListener('keydown', cancelCeilingHoleMove) + } + }, [ceilingById, ceilingHoleMoveDraft, getPlanPointFromClientPoint, updateNode]) + + useEffect(() => { + const dragState = zoneVertexDragState + if (!dragState) { + return + } + + const handleWindowPointerMove = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + event.preventDefault() + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } + + const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + setCursorPoint(snappedPoint) + + setZoneBoundaryDraft((currentDraft) => { + if (!currentDraft || currentDraft.zoneId !== dragState.zoneId) { + return currentDraft + } + + const currentPoint = currentDraft.polygon[dragState.vertexIndex] + if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { + return currentDraft + } + + sfxEmitter.emit('sfx:grid-snap') + + const nextPolygon = [...currentDraft.polygon] + nextPolygon[dragState.vertexIndex] = snappedPoint + + return { + ...currentDraft, + polygon: nextPolygon, + } + }) + } + + const commitZoneVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + const draft = zoneBoundaryDraftRef.current + const zone = zoneById.get(dragState.zoneId) + if (draft && zone && !polygonsEqual(draft.polygon, zone.polygon)) { + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + updateNode(draft.zoneId, { + polygon: draft.polygon, + }) + sfxEmitter.emit('sfx:structure-build') + } + + clearZoneBoundaryInteraction() + setCursorPoint(null) + } + + const cancelZoneVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + clearZoneBoundaryInteraction() + setCursorPoint(null) + } + + window.addEventListener('pointermove', handleWindowPointerMove) + window.addEventListener('pointerup', commitZoneVertexDrag) + window.addEventListener('pointercancel', cancelZoneVertexDrag) + + return () => { + window.removeEventListener('pointermove', handleWindowPointerMove) + window.removeEventListener('pointerup', commitZoneVertexDrag) + window.removeEventListener('pointercancel', cancelZoneVertexDrag) + } + }, [ + clearZoneBoundaryInteraction, + getPlanPointFromClientPoint, + updateNode, + zoneById, + zoneVertexDragState, + ]) + + useEffect(() => { + return () => { + setFloorplanHovered(false) + } + }, [setFloorplanHovered]) + + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 2) { + return + } + + event.preventDefault() + event.stopPropagation() + + panStateRef.current = { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + } + setIsPanning(true) + + event.currentTarget.setPointerCapture(event.pointerId) + }, []) + + const endPanning = useCallback((event?: ReactPointerEvent) => { + if (event && panStateRef.current && event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + + panStateRef.current = null + setIsPanning(false) + }, []) + + const hoveredWallIdRef = useRef(null) + const floorplanGridLocalY = useMemo(() => { + if (movingNode?.type === 'item') { + return movingNode.position[1] + } + + if (levelId) { + return sceneRegistry.nodes.get(levelId as AnyNodeId)?.position.y ?? 0 + } + + return 0 + }, [levelId, movingNode]) + const floorplanGridWorldY = buildingPosition[1] + floorplanGridLocalY + const emitFloorplanWallLeave = useCallback((wallId: string | null) => { + if (!wallId) { + return + } + + const wallNode = useScene.getState().nodes[wallId as AnyNodeId] + if (!wallNode || wallNode.type !== 'wall') { + return + } + + emitter.emit('wall:leave', { + node: wallNode, + position: [0, 0, 0], + localPosition: [0, 0, 0], + stopPropagation: () => {}, + } as any) + }, []) + const emitFloorplanGridEvent = useCallback( + ( + eventType: 'move' | 'click' | 'double-click', + planPoint: WallPlanPoint, + nativeEvent: ReactMouseEvent | ReactPointerEvent, + ) => { + const snappedPoint = getSnappedFloorplanPoint(planPoint) + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + const worldX = buildingPosition[0] + snappedPoint[0] * cos - snappedPoint[1] * sin + const worldZ = buildingPosition[2] + snappedPoint[0] * sin + snappedPoint[1] * cos + + emitter.emit(`grid:${eventType}` as any, { + nativeEvent: nativeEvent.nativeEvent as any, + position: [worldX, floorplanGridWorldY, worldZ], + localPosition: [snappedPoint[0], floorplanGridLocalY, snappedPoint[1]], }) return snappedPoint @@ -8753,6 +11151,26 @@ export function FloorplanPanel() { return } + if (ceilingHoleMoveDraft) { + return + } + + if (ceilingHoleVertexDragState?.pointerId === event.pointerId) { + return + } + + if (ceilingVertexDragState?.pointerId === event.pointerId) { + return + } + + if (slabHoleMoveDraft) { + return + } + + if (slabHoleVertexDragState?.pointerId === event.pointerId) { + return + } + if (slabVertexDragState?.pointerId === event.pointerId) { return } @@ -8770,6 +11188,23 @@ export function FloorplanPanel() { return } + if (referenceScaleDraft) { + emitFloorplanGridEvent('move', planPoint, event) + + setCursorPoint((previousPoint) => + previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint, + ) + setReferenceScaleDraft((currentDraft) => + currentDraft + ? { + ...currentDraft, + cursor: planPoint, + } + : currentDraft, + ) + return + } + if (isCeilingBuildActive) { emitFloorplanGridEvent('move', planPoint, event) @@ -8943,8 +11378,14 @@ export function FloorplanPanel() { isPolygonBuildActive, isRoofBuildActive, isWallBuildActive, + referenceScaleDraft, roofDraftStart, + ceilingHoleMoveDraft, + ceilingHoleVertexDragState, + ceilingVertexDragState, siteVertexDragState, + slabHoleMoveDraft, + slabHoleVertexDragState, slabVertexDragState, shiftPressed, surfaceSize.height, @@ -9111,7 +11552,7 @@ export function FloorplanPanel() { [clearDraft, draftStart], ) const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ - ceilingPolygons, + ceilingPolygons: displayCeilingPolygons, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, @@ -9172,6 +11613,44 @@ export function FloorplanPanel() { return } + if (referenceScaleDraft) { + event.preventDefault() + event.stopPropagation() + + emitFloorplanGridEvent('click', planPoint, event) + + if (!referenceScaleDraft.start) { + setReferenceScaleDraft({ + ...referenceScaleDraft, + start: planPoint, + cursor: planPoint, + }) + setCursorPoint(planPoint) + return + } + + const measuredLengthUnits = Math.hypot( + planPoint[0] - referenceScaleDraft.start[0], + planPoint[1] - referenceScaleDraft.start[1], + ) + + if (measuredLengthUnits < 1e-6) { + return + } + + setPendingReferenceScale({ + guideId: referenceScaleDraft.guideId, + start: referenceScaleDraft.start, + end: planPoint, + measuredLengthUnits, + }) + setReferenceScaleValue(formatNumber(measuredLengthUnits, 2)) + setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') + setReferenceScaleDraft(null) + setCursorPoint(null) + return + } + if (handleBackgroundPlacementClick(planPoint, event, draftStart)) { return } @@ -9247,12 +11726,14 @@ export function FloorplanPanel() { isWallBuildActive, levelId, levelNode, + referenceScaleDraft, setSelectedReferenceId, setSelection, structureLayer, getFloorplanHitIdAtPoint, - toPoint2D, + unit, visibleZonePolygons, + emitFloorplanGridEvent, ], ) const handleBackgroundDoubleClick = useCallback( @@ -9653,7 +12134,7 @@ export function FloorplanPanel() { corner: GuideCorner, event: ReactPointerEvent, ) => { - if (event.button !== 0 || !canInteractWithGuides) { + if (event.button !== 0 || !canInteractWithGuides || guideUi[guide.id]?.locked === true) { return } @@ -9669,7 +12150,7 @@ export function FloorplanPanel() { handleGuideSelect(guide.id) const centerSvg = getGuideCenterSvgPoint(guide) - const rotationSvg = -guide.rotation[1] + const rotationSvg = getGuideSvgRotation(guide.rotation[1]) const width = getGuideWidth(guide.scale) const height = getGuideHeight(width, aspectRatio) const [cornerOffsetX, cornerOffsetY] = getGuideCornerLocalOffset(width, height, corner) @@ -9712,11 +12193,16 @@ export function FloorplanPanel() { guideTransformDraftRef.current = nextDraft setGuideTransformDraft(nextDraft) }, - [canInteractWithGuides, handleGuideSelect, theme], + [canInteractWithGuides, guideUi, handleGuideSelect, theme], ) const handleGuideTranslateStart = useCallback( (guide: GuideNode, event: ReactPointerEvent) => { - if (event.button !== 0 || !canInteractWithGuides || selectedGuideId !== guide.id) { + if ( + event.button !== 0 || + !canInteractWithGuides || + selectedGuideId !== guide.id || + guideUi[guide.id]?.locked === true + ) { return } @@ -9739,7 +12225,7 @@ export function FloorplanPanel() { centerSvg, oppositeCornerSvg: null, pointerOffsetSvg: subtractSvgPoints(svgPoint, centerSvg), - rotationSvg: -guide.rotation[1], + rotationSvg: getGuideSvgRotation(guide.rotation[1]), cornerBaseAngle: 0, scale: guide.scale, } @@ -9757,7 +12243,7 @@ export function FloorplanPanel() { guideTransformDraftRef.current = nextDraft setGuideTransformDraft(nextDraft) }, - [canInteractWithGuides, getSvgPointFromClientPoint, selectedGuideId], + [canInteractWithGuides, getSvgPointFromClientPoint, guideUi, selectedGuideId], ) const handleOpeningSelect = useCallback( @@ -9998,6 +12484,111 @@ export function FloorplanPanel() { }, [selectedSlabEntry, setMovingNode, setSelection], ) + const handleSelectedSlabAddHole = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const slab = selectedSlabEntry?.slab + if (!(slab && slab.polygon.length > 0)) { + return + } + + const [sumX, sumZ] = slab.polygon.reduce( + ([currentX, currentZ], [x, z]) => [currentX + x, currentZ + z], + [0, 0], + ) + const cx = sumX / slab.polygon.length + const cz = sumZ / slab.polygon.length + const holeSize = 0.5 + const newHole: Array<[number, number]> = [ + [cx - holeSize, cz - holeSize], + [cx + holeSize, cz - holeSize], + [cx + holeSize, cz + holeSize], + [cx - holeSize, cz + holeSize], + ] + const currentHoles = slab.holes ?? [] + const currentMetadata = currentHoles.map( + (_, index) => slab.holeMetadata?.[index] ?? { source: 'manual' as const }, + ) + + updateNode(slab.id, { + holes: [...currentHoles, newHole], + holeMetadata: [...currentMetadata, { source: 'manual' }], + }) + setEditingHole({ nodeId: slab.id, holeIndex: currentHoles.length }) + sfxEmitter.emit('sfx:structure-build') + }, + [selectedSlabEntry, setEditingHole, updateNode], + ) + const handleSelectedSlabHoleMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const slab = selectedSlabEntry?.slab + const holeIndex = selectedSlabEditingHoleIndex + const hole = selectedSlabEditingHole + if (!(slab && holeIndex !== null && hole && hole.length > 0)) { + return + } + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + const [sumX, sumY] = hole.reduce( + ([currentX, currentY], point) => [currentX + point.x, currentY + point.y], + [0, 0], + ) + const startPlanPoint = + planPoint ?? ([sumX / hole.length, sumY / hole.length] as WallPlanPoint) + const originalPolygon = hole.map(toWallPlanPoint) + + setSlabHoleBoundaryDraft(null) + setSlabHoleVertexDragState(null) + setSlabHoleMoveDraft({ + slabId: slab.id, + holeIndex, + polygon: originalPolygon, + originalPolygon, + startPlanPoint, + }) + setCursorPoint(startPlanPoint) + sfxEmitter.emit('sfx:item-pick') + }, + [ + getPlanPointFromClientPoint, + selectedSlabEditingHole, + selectedSlabEditingHoleIndex, + selectedSlabEntry, + ], + ) + const handleSelectedSlabHoleDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const slab = selectedSlabEntry?.slab + const holeIndex = selectedSlabEditingHoleIndex + if (!(slab && holeIndex !== null)) { + return + } + + const currentHoles = slab.holes ?? [] + if (!currentHoles[holeIndex] || slab.holeMetadata?.[holeIndex]?.source === 'stair') { + return + } + + const currentMetadata = currentHoles.map( + (_, index) => slab.holeMetadata?.[index] ?? { source: 'manual' as const }, + ) + updateNode(slab.id, { + holes: currentHoles.filter((_, index) => index !== holeIndex), + holeMetadata: currentMetadata.filter((_, index) => index !== holeIndex), + }) + setEditingHole(null) + setSlabHoleBoundaryDraft(null) + setSlabHoleMoveDraft(null) + setSlabHoleVertexDragState(null) + sfxEmitter.emit('sfx:item-delete') + }, + [selectedSlabEditingHoleIndex, selectedSlabEntry, setEditingHole, updateNode], + ) const handleSelectedSlabDelete = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -10007,26 +12598,131 @@ export function FloorplanPanel() { return } - sfxEmitter.emit('sfx:item-delete') - deleteNode(slab.id as AnyNodeId) - setSelection({ selectedIds: [] }) + sfxEmitter.emit('sfx:item-delete') + deleteNode(slab.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedSlabEntry, setSelection], + ) + const handleSelectedCeilingMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const ceiling = selectedCeilingEntry?.ceiling + if (!ceiling) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(ceiling) + setSelection({ selectedIds: [] }) + }, + [selectedCeilingEntry, setMovingNode, setSelection], + ) + const handleSelectedCeilingAddHole = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const ceiling = selectedCeilingEntry?.ceiling + if (!(ceiling && ceiling.polygon.length > 0)) { + return + } + + const [sumX, sumZ] = ceiling.polygon.reduce( + ([currentX, currentZ], [x, z]) => [currentX + x, currentZ + z], + [0, 0], + ) + const cx = sumX / ceiling.polygon.length + const cz = sumZ / ceiling.polygon.length + const holeSize = 0.5 + const newHole: Array<[number, number]> = [ + [cx - holeSize, cz - holeSize], + [cx + holeSize, cz - holeSize], + [cx + holeSize, cz + holeSize], + [cx - holeSize, cz + holeSize], + ] + const currentHoles = ceiling.holes ?? [] + const currentMetadata = currentHoles.map( + (_, index) => ceiling.holeMetadata?.[index] ?? { source: 'manual' as const }, + ) + + updateNode(ceiling.id, { + holes: [...currentHoles, newHole], + holeMetadata: [...currentMetadata, { source: 'manual' }], + }) + setEditingHole({ nodeId: ceiling.id, holeIndex: currentHoles.length }) + sfxEmitter.emit('sfx:structure-build') + }, + [selectedCeilingEntry, setEditingHole, updateNode], + ) + const handleSelectedCeilingHoleMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const ceiling = selectedCeilingEntry?.ceiling + const holeIndex = selectedCeilingEditingHoleIndex + const hole = selectedCeilingEditingHole + if (!(ceiling && holeIndex !== null && hole && hole.length > 0)) { + return + } + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + const [sumX, sumY] = hole.reduce( + ([currentX, currentY], point) => [currentX + point.x, currentY + point.y], + [0, 0], + ) + const startPlanPoint = + planPoint ?? ([sumX / hole.length, sumY / hole.length] as WallPlanPoint) + const originalPolygon = hole.map(toWallPlanPoint) + + setCeilingHoleBoundaryDraft(null) + setCeilingHoleVertexDragState(null) + setCeilingHoleMoveDraft({ + ceilingId: ceiling.id, + holeIndex, + polygon: originalPolygon, + originalPolygon, + startPlanPoint, + }) + setCursorPoint(startPlanPoint) + sfxEmitter.emit('sfx:item-pick') }, - [deleteNode, selectedSlabEntry, setSelection], + [ + getPlanPointFromClientPoint, + selectedCeilingEditingHole, + selectedCeilingEditingHoleIndex, + selectedCeilingEntry, + ], ) - const handleSelectedCeilingMove = useCallback( + const handleSelectedCeilingHoleDelete = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() const ceiling = selectedCeilingEntry?.ceiling - if (!ceiling) { + const holeIndex = selectedCeilingEditingHoleIndex + if (!(ceiling && holeIndex !== null)) { return } - sfxEmitter.emit('sfx:item-pick') - setMovingNode(ceiling) - setSelection({ selectedIds: [] }) + const currentHoles = ceiling.holes ?? [] + if (!currentHoles[holeIndex] || ceiling.holeMetadata?.[holeIndex]?.source === 'stair') { + return + } + + const currentMetadata = currentHoles.map( + (_, index) => ceiling.holeMetadata?.[index] ?? { source: 'manual' as const }, + ) + updateNode(ceiling.id, { + holes: currentHoles.filter((_, index) => index !== holeIndex), + holeMetadata: currentMetadata.filter((_, index) => index !== holeIndex), + }) + setEditingHole(null) + setCeilingHoleBoundaryDraft(null) + setCeilingHoleMoveDraft(null) + setCeilingHoleVertexDragState(null) + sfxEmitter.emit('sfx:item-delete') }, - [selectedCeilingEntry, setMovingNode, setSelection], + [selectedCeilingEditingHoleIndex, selectedCeilingEntry, setEditingHole, updateNode], ) const handleSelectedCeilingDelete = useCallback( (event: ReactMouseEvent) => { @@ -10310,107 +13006,416 @@ export function FloorplanPanel() { (event: ReactMouseEvent) => { event.stopPropagation() - const roof = selectedRoofEntry?.roof - if (!roof) { + const roof = selectedRoofEntry?.roof + if (!roof) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(roof.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedRoofEntry, setSelection], + ) + + const handleWallEndpointPointerDown = useCallback( + (wall: WallNode, endpoint: WallEndpoint, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredEndpointId(null) + + const movingPoint = endpoint === 'start' ? wall.start : wall.end + + if (isWallBuildActive) { + handleWallPlacementPoint(movingPoint) + return + } + + if (mode !== 'select') { + return + } + + clearWallPlacementDraft() + handleWallSelect(wall) + + const fixedPoint = endpoint === 'start' ? wall.end : wall.start + const originalStart = [...wall.start] as WallPlanPoint + const originalEnd = [...wall.end] as WallPlanPoint + const linkedWalls = getLinkedWallSnapshots(walls, wall.id, originalStart, originalEnd) + + wallEndpointDragRef.current = { + pointerId: event.pointerId, + wallId: wall.id, + endpoint, + fixedPoint, + currentPoint: movingPoint, + originalStart, + originalEnd, + linkedWalls, + } + + setWallEndpointDraft( + buildWallEndpointDraft(wall.id, endpoint, fixedPoint, movingPoint, linkedWalls), + ) + setCursorPoint(movingPoint) + }, + [ + clearWallPlacementDraft, + handleWallPlacementPoint, + handleWallSelect, + isWallBuildActive, + mode, + walls, + ], + ) + const handleWallCurvePointerDown = useCallback( + (wall: WallNode, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredWallCurveHandleId(null) + + if (isWallBuildActive || mode !== 'select') { + return + } + + clearWallPlacementDraft() + handleWallSelect(wall) + clearWallEndpointDrag() + + const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0) + wallCurveDragRef.current = { + pointerId: event.pointerId, + wallId: wall.id, + currentCurveOffset, + } + setWallCurveDraft({ + wallId: wall.id, + curveOffset: currentCurveOffset, + }) + const center = getWallMidpointHandlePoint(wall) + setCursorPoint([center.x, center.y]) + }, + [clearWallEndpointDrag, clearWallPlacementDraft, handleWallSelect, isWallBuildActive, mode], + ) + const handleSlabVertexPointerDown = useCallback( + (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredSlabHandleId(null) + + const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) + const vertexPoint = slabEntry?.polygon[vertexIndex] + const handlePolygon = slabEntry ? getSlabHandlePolygon(slabEntry) : [] + const handlePoint = + vertexPoint && handlePolygon.length > 0 + ? handlePolygon[getClosestPolygonVertexIndex(vertexPoint, handlePolygon)] + : null + if (!(slabEntry && vertexPoint && handlePoint)) { + return + } + + const visualOffsets = getSlabVisualOffsets(slabEntry) + + setSlabBoundaryDraft({ + slabId, + polygon: slabEntry.polygon.map(toWallPlanPoint), + visualOffsets, + }) + setSlabVertexDragState({ + pointerId: event.pointerId, + slabId, + vertexIndex, + visualOffset: { + x: handlePoint.x - vertexPoint.x, + y: handlePoint.y - vertexPoint.y, + }, + }) + setCursorPoint(toWallPlanPoint(handlePoint)) + }, + [displaySlabPolygons], + ) + const handleSlabVertexDoubleClick = useCallback( + (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + + const slab = slabById.get(slabId) + if (!(slab && slab.polygon.length > 3)) { + return + } + + slabBoundaryDraftRef.current = null + clearSlabBoundaryInteraction() + + updateNode(slabId, { + polygon: slab.polygon.filter((_, index) => index !== vertexIndex), + }) + }, + [clearSlabBoundaryInteraction, slabById, updateNode], + ) + const handleSlabMidpointPointerDown = useCallback( + ( + slabId: SlabNode['id'], + handleEdgeIndex: number, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredSlabHandleId(null) + + const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) + if (!slabEntry) { + return + } + + const basePolygon = slabEntry.polygon.map(toWallPlanPoint) + const handlePolygon = getSlabHandlePolygon(slabEntry) + const handleStartPoint = handlePolygon[handleEdgeIndex] + const handleEndPoint = handlePolygon[(handleEdgeIndex + 1) % handlePolygon.length] + const insertedHandlePoint: WallPlanPoint = + handleStartPoint && handleEndPoint + ? [ + (handleStartPoint.x + handleEndPoint.x) / 2, + (handleStartPoint.y + handleEndPoint.y) / 2, + ] + : (basePolygon[handleEdgeIndex] ?? basePolygon[0] ?? ([0, 0] as WallPlanPoint)) + const edgeIndex = getClosestPolygonEdgeIndex( + toPoint2D(insertedHandlePoint), + slabEntry.polygon, + ) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { + return + } + const insertedPoint: WallPlanPoint = [ + (startPoint[0] + endPoint[0]) / 2, + (startPoint[1] + endPoint[1]) / 2, + ] + const insertIndex = edgeIndex + 1 + const nextPolygon = [ + ...basePolygon.slice(0, insertIndex), + insertedPoint, + ...basePolygon.slice(insertIndex), + ] + const visualOffsets = getSlabVisualOffsets(slabEntry) + const insertedVisualOffset = { + x: insertedHandlePoint[0] - insertedPoint[0], + y: insertedHandlePoint[1] - insertedPoint[1], + } + const nextVisualOffsets = [ + ...visualOffsets.slice(0, insertIndex), + insertedVisualOffset, + ...visualOffsets.slice(insertIndex), + ] + + setSlabBoundaryDraft({ + slabId, + polygon: nextPolygon, + visualOffsets: nextVisualOffsets, + }) + setSlabVertexDragState({ + pointerId: event.pointerId, + slabId, + vertexIndex: insertIndex, + visualOffset: insertedVisualOffset, + }) + setCursorPoint(insertedHandlePoint) + }, + [displaySlabPolygons], + ) + const handleCeilingVertexPointerDown = useCallback( + ( + ceilingId: CeilingNode['id'], + vertexIndex: number, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredCeilingHandleId(null) + + const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) + const vertexPoint = ceilingEntry?.polygon[vertexIndex] + if (!(ceilingEntry && vertexPoint)) { + return + } + + setCeilingBoundaryDraft({ + ceilingId, + polygon: ceilingEntry.polygon.map(toWallPlanPoint), + }) + setCeilingVertexDragState({ + pointerId: event.pointerId, + ceilingId, + vertexIndex, + }) + setCursorPoint(toWallPlanPoint(vertexPoint)) + }, + [displayCeilingPolygons], + ) + const handleCeilingVertexDoubleClick = useCallback( + ( + ceilingId: CeilingNode['id'], + vertexIndex: number, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + + const ceiling = ceilingById.get(ceilingId) + if (!(ceiling && ceiling.polygon.length > 3)) { return } - sfxEmitter.emit('sfx:item-delete') - deleteNode(roof.id as AnyNodeId) - setSelection({ selectedIds: [] }) + ceilingBoundaryDraftRef.current = null + clearCeilingBoundaryInteraction() + + updateNode(ceilingId, { + polygon: ceiling.polygon.filter((_, index) => index !== vertexIndex), + }) }, - [deleteNode, selectedRoofEntry, setSelection], + [ceilingById, clearCeilingBoundaryInteraction, updateNode], ) - - const handleWallEndpointPointerDown = useCallback( - (wall: WallNode, endpoint: WallEndpoint, event: ReactPointerEvent) => { + const handleCeilingMidpointPointerDown = useCallback( + ( + ceilingId: CeilingNode['id'], + edgeIndex: number, + event: ReactPointerEvent, + ) => { if (event.button !== 0) { return } event.preventDefault() event.stopPropagation() - setHoveredEndpointId(null) - - const movingPoint = endpoint === 'start' ? wall.start : wall.end + setHoveredCeilingHandleId(null) - if (isWallBuildActive) { - handleWallPlacementPoint(movingPoint) + const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) + if (!ceilingEntry) { return } - if (mode !== 'select') { + const basePolygon = ceilingEntry.polygon.map(toWallPlanPoint) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { return } - clearWallPlacementDraft() - handleWallSelect(wall) - - const fixedPoint = endpoint === 'start' ? wall.end : wall.start - const originalStart = [...wall.start] as WallPlanPoint - const originalEnd = [...wall.end] as WallPlanPoint - const linkedWalls = getLinkedWallSnapshots(walls, wall.id, originalStart, originalEnd) + const insertedPoint: WallPlanPoint = [ + (startPoint[0] + endPoint[0]) / 2, + (startPoint[1] + endPoint[1]) / 2, + ] + const insertIndex = edgeIndex + 1 + const nextPolygon = [ + ...basePolygon.slice(0, insertIndex), + insertedPoint, + ...basePolygon.slice(insertIndex), + ] - wallEndpointDragRef.current = { + setCeilingBoundaryDraft({ + ceilingId, + polygon: nextPolygon, + }) + setCeilingVertexDragState({ pointerId: event.pointerId, - wallId: wall.id, - endpoint, - fixedPoint, - currentPoint: movingPoint, - originalStart, - originalEnd, - linkedWalls, + ceilingId, + vertexIndex: insertIndex, + }) + setCursorPoint(insertedPoint) + }, + [displayCeilingPolygons], + ) + const handleSlabHoleVertexPointerDown = useCallback( + (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return } - setWallEndpointDraft( - buildWallEndpointDraft(wall.id, endpoint, fixedPoint, movingPoint, linkedWalls), - ) - setCursorPoint(movingPoint) + event.preventDefault() + event.stopPropagation() + setHoveredSlabHandleId(null) + + const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) + const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? slabEntry?.holes[holeIndex] : null + const vertexPoint = hole?.[vertexIndex] + if (!(slabEntry && holeIndex !== null && hole && vertexPoint)) { + return + } + + setSlabHoleBoundaryDraft({ + slabId, + holeIndex, + polygon: hole.map(toWallPlanPoint), + }) + setSlabHoleVertexDragState({ + pointerId: event.pointerId, + slabId, + holeIndex, + vertexIndex, + }) + setCursorPoint(toWallPlanPoint(vertexPoint)) }, - [ - clearWallPlacementDraft, - handleWallPlacementPoint, - handleWallSelect, - isWallBuildActive, - mode, - walls, - ], + [displaySlabPolygons, editingHole], ) - const handleWallCurvePointerDown = useCallback( - (wall: WallNode, event: ReactPointerEvent) => { + const handleSlabHoleVertexDoubleClick = useCallback( + (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { return } event.preventDefault() event.stopPropagation() - setHoveredWallCurveHandleId(null) - if (isWallBuildActive || mode !== 'select') { + const slab = slabById.get(slabId) + const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? slab?.holes?.[holeIndex] : null + if (!(slab && holeIndex !== null && hole && hole.length > 3)) { return } - clearWallPlacementDraft() - handleWallSelect(wall) - clearWallEndpointDrag() + slabHoleBoundaryDraftRef.current = null + clearSlabHoleBoundaryInteraction() - const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0) - wallCurveDragRef.current = { - pointerId: event.pointerId, - wallId: wall.id, - currentCurveOffset, - } - setWallCurveDraft({ - wallId: wall.id, - curveOffset: currentCurveOffset, + const nextHoles = [...(slab.holes ?? [])] + nextHoles[holeIndex] = hole.filter((_, index) => index !== vertexIndex) + updateNode(slabId, { + holes: nextHoles, }) - const center = getWallMidpointHandlePoint(wall) - setCursorPoint([center.x, center.y]) }, - [clearWallEndpointDrag, clearWallPlacementDraft, handleWallSelect, isWallBuildActive, mode], + [clearSlabHoleBoundaryInteraction, editingHole, slabById, updateNode], ) - const handleSlabVertexPointerDown = useCallback( - (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { + const handleSlabHoleMidpointPointerDown = useCallback( + (slabId: SlabNode['id'], edgeIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { return } @@ -10420,26 +13425,88 @@ export function FloorplanPanel() { setHoveredSlabHandleId(null) const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - const vertexPoint = slabEntry?.polygon[vertexIndex] - if (!(slabEntry && vertexPoint)) { + const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? slabEntry?.holes[holeIndex] : null + if (!(slabEntry && holeIndex !== null && hole)) { return } - setSlabBoundaryDraft({ + const basePolygon = hole.map(toWallPlanPoint) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { + return + } + + const insertedPoint: WallPlanPoint = [ + (startPoint[0] + endPoint[0]) / 2, + (startPoint[1] + endPoint[1]) / 2, + ] + const insertIndex = edgeIndex + 1 + const nextPolygon = [ + ...basePolygon.slice(0, insertIndex), + insertedPoint, + ...basePolygon.slice(insertIndex), + ] + + setSlabHoleBoundaryDraft({ slabId, - polygon: slabEntry.polygon.map(toWallPlanPoint), + holeIndex, + polygon: nextPolygon, }) - setSlabVertexDragState({ + setSlabHoleVertexDragState({ pointerId: event.pointerId, slabId, + holeIndex, + vertexIndex: insertIndex, + }) + setCursorPoint(insertedPoint) + }, + [displaySlabPolygons, editingHole], + ) + const handleCeilingHoleVertexPointerDown = useCallback( + ( + ceilingId: CeilingNode['id'], + vertexIndex: number, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredCeilingHandleId(null) + + const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) + const holeIndex = editingHole?.nodeId === ceilingId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? ceilingEntry?.holes[holeIndex] : null + const vertexPoint = hole?.[vertexIndex] + if (!(ceilingEntry && holeIndex !== null && hole && vertexPoint)) { + return + } + + setCeilingHoleBoundaryDraft({ + ceilingId, + holeIndex, + polygon: hole.map(toWallPlanPoint), + }) + setCeilingHoleVertexDragState({ + pointerId: event.pointerId, + ceilingId, + holeIndex, vertexIndex, }) setCursorPoint(toWallPlanPoint(vertexPoint)) }, - [displaySlabPolygons], + [displayCeilingPolygons, editingHole], ) - const handleSlabVertexDoubleClick = useCallback( - (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { + const handleCeilingHoleVertexDoubleClick = useCallback( + ( + ceilingId: CeilingNode['id'], + vertexIndex: number, + event: ReactPointerEvent, + ) => { if (event.button !== 0) { return } @@ -10447,36 +13514,46 @@ export function FloorplanPanel() { event.preventDefault() event.stopPropagation() - const slab = slabById.get(slabId) - if (!(slab && slab.polygon.length > 3)) { + const ceiling = ceilingById.get(ceilingId) + const holeIndex = editingHole?.nodeId === ceilingId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? ceiling?.holes?.[holeIndex] : null + if (!(ceiling && holeIndex !== null && hole && hole.length > 3)) { return } - slabBoundaryDraftRef.current = null - clearSlabBoundaryInteraction() + ceilingHoleBoundaryDraftRef.current = null + clearCeilingHoleBoundaryInteraction() - updateNode(slabId, { - polygon: slab.polygon.filter((_, index) => index !== vertexIndex), + const nextHoles = [...(ceiling.holes ?? [])] + nextHoles[holeIndex] = hole.filter((_, index) => index !== vertexIndex) + updateNode(ceilingId, { + holes: nextHoles, }) }, - [clearSlabBoundaryInteraction, slabById, updateNode], + [ceilingById, clearCeilingHoleBoundaryInteraction, editingHole, updateNode], ) - const handleSlabMidpointPointerDown = useCallback( - (slabId: SlabNode['id'], edgeIndex: number, event: ReactPointerEvent) => { + const handleCeilingHoleMidpointPointerDown = useCallback( + ( + ceilingId: CeilingNode['id'], + edgeIndex: number, + event: ReactPointerEvent, + ) => { if (event.button !== 0) { return } event.preventDefault() event.stopPropagation() - setHoveredSlabHandleId(null) + setHoveredCeilingHandleId(null) - const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - if (!slabEntry) { + const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) + const holeIndex = editingHole?.nodeId === ceilingId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? ceilingEntry?.holes[holeIndex] : null + if (!(ceilingEntry && holeIndex !== null && hole)) { return } - const basePolygon = slabEntry.polygon.map(toWallPlanPoint) + const basePolygon = hole.map(toWallPlanPoint) const startPoint = basePolygon[edgeIndex] const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] if (!(startPoint && endPoint)) { @@ -10494,18 +13571,20 @@ export function FloorplanPanel() { ...basePolygon.slice(insertIndex), ] - setSlabBoundaryDraft({ - slabId, + setCeilingHoleBoundaryDraft({ + ceilingId, + holeIndex, polygon: nextPolygon, }) - setSlabVertexDragState({ + setCeilingHoleVertexDragState({ pointerId: event.pointerId, - slabId, + ceilingId, + holeIndex, vertexIndex: insertIndex, }) setCursorPoint(insertedPoint) }, - [displaySlabPolygons], + [displayCeilingPolygons, editingHole], ) const handleSiteVertexPointerDown = useCallback( (siteId: SiteNode['id'], vertexIndex: number, event: ReactPointerEvent) => { @@ -10721,7 +13800,12 @@ export function FloorplanPanel() { !( panStateRef.current || wallEndpointDragRef.current || + ceilingVertexDragState || + ceilingHoleMoveDraft || + ceilingHoleVertexDragState || siteVertexDragState || + slabHoleMoveDraft || + slabHoleVertexDragState || slabVertexDragState || zoneVertexDragState ) @@ -10738,6 +13822,7 @@ export function FloorplanPanel() { setHoveredEndpointId(null) setHoveredSiteHandleId(null) setHoveredSlabHandleId(null) + setHoveredCeilingHandleId(null) setHoveredZoneHandleId(null) if (hoveredWallIdRef.current) { emitFloorplanWallLeave(hoveredWallIdRef.current) @@ -10752,7 +13837,12 @@ export function FloorplanPanel() { handleStairHoverChange, handleWallHoverChange, handleZoneHoverChange, + ceilingVertexDragState, + ceilingHoleMoveDraft, + ceilingHoleVertexDragState, siteVertexDragState, + slabHoleMoveDraft, + slabHoleVertexDragState, slabVertexDragState, zoneVertexDragState, ]) @@ -10774,7 +13864,12 @@ export function FloorplanPanel() { !panStateRef.current && !guideInteractionRef.current && !wallEndpointDragRef.current && + !ceilingVertexDragState && + !ceilingHoleMoveDraft && + !ceilingHoleVertexDragState && !siteVertexDragState && + !slabHoleMoveDraft && + !slabHoleVertexDragState && !slabVertexDragState && !zoneVertexDragState ) { @@ -10801,7 +13896,12 @@ export function FloorplanPanel() { [ handlePointerMove, hasFloorplanCursorIndicator, + ceilingVertexDragState, + ceilingHoleMoveDraft, + ceilingHoleVertexDragState, siteVertexDragState, + slabHoleMoveDraft, + slabHoleVertexDragState, slabVertexDragState, zoneVertexDragState, ], @@ -11200,7 +14300,12 @@ export function FloorplanPanel() { selectedStairEntry, ]) const activeDraftAnchorPoint = - draftStart ?? fenceDraftStart ?? roofDraftStart ?? activePolygonDraftPoints[0] ?? null + referenceScaleDraft?.start ?? + draftStart ?? + fenceDraftStart ?? + roofDraftStart ?? + activePolygonDraftPoints[0] ?? + null const floorplanCursorColor = mode === 'delete' ? palette.deleteStroke @@ -11209,6 +14314,25 @@ export function FloorplanPanel() { : activeDraftAnchorPoint ? palette.draftStroke : palette.cursor + const pendingReferenceDisplayLength = Number(referenceScaleValue) + const pendingReferenceRealLengthMeters = + pendingReferenceScale && pendingReferenceDisplayLength > 0 + ? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit) + : null + const pendingReferenceMetersPerUnit = + pendingReferenceScale && pendingReferenceRealLengthMeters + ? pendingReferenceRealLengthMeters / pendingReferenceScale.measuredLengthUnits + : null + const pendingReferenceImageScaleFactor = + pendingReferenceScale && pendingReferenceRealLengthMeters + ? pendingReferenceRealLengthMeters / pendingReferenceScale.measuredLengthUnits + : null + const referenceScaleInputError = + referenceScaleValue.trim() === '' + ? 'Enter the real length of the line.' + : pendingReferenceDisplayLength > 0 + ? null + : 'Length must be greater than 0.' return (
+ {referenceScaleDraft && ( +
+ {referenceScaleDraft.start + ? 'Click the end of the known distance' + : 'Click the start of a known distance'} +
+ )} + + {pendingReferenceScale && ( +
{ + event.preventDefault() + handleReferenceScaleConfirm() + }} + > +
+
+ +
+
+
Set overlay scale
+
+ Enter the real-world length of the line you just drew. The image will resize to + match it. +
+
+
+ +
+
+ Drawn line +
+
+ {formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)} +
+
+ + + +
+ {pendingReferenceImageScaleFactor + ? `Image will scale ${formatNumber(pendingReferenceImageScaleFactor, 3)}x from the first point.` + : 'Enter a length greater than 0.'} +
+ +
+ + +
+
+ )} + {!levelNode || levelNode.type !== 'level' ? (
Switch to a building level to view and edit the floorplan. @@ -11308,7 +14550,7 @@ export function FloorplanPanel() { onPointerMove={handleSvgPointerMove} onPointerUp={endPanning} ref={svgRef} - style={{ cursor: EDITOR_CURSOR }} + style={{ cursor: referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR }} viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`} > @@ -11328,6 +14570,22 @@ export function FloorplanPanel() { y2={wallSelectionHatchSpacing} /> + + + + + @@ -11446,14 +14713,25 @@ export function FloorplanPanel() { selectedIdSet={selectedIdSet} stairEntries={renderedFloorplanStairEntries} unit={unit} + wallSelectionHatchId={wallSelectionHatchId} /> + + @@ -11525,11 +14804,22 @@ export function FloorplanPanel() { ({ - x: toSvgX(point[0]), - y: toSvgY(point[1]), - isPrimary: index === 0, - }))} + draftAnchorPoints={[ + ...(referenceScaleDraft?.start + ? [ + { + x: toSvgX(referenceScaleDraft.start[0]), + y: toSvgY(referenceScaleDraft.start[1]), + isPrimary: true, + }, + ] + : []), + ...activePolygonDraftPoints.map((point, index) => ({ + x: toSvgX(point[0]), + y: toSvgY(point[1]), + isPrimary: index === 0, + })), + ]} draftFill={palette.draftFill} draftPolygonPoints={draftPolygonPoints} draftStroke={palette.draftStroke} @@ -11537,6 +14827,15 @@ export function FloorplanPanel() { polygonDraftClosingSegment={polygonDraftClosingSegment} polygonDraftPolygonPoints={polygonDraftPolygonPoints} polygonDraftPolylinePoints={polygonDraftPolylinePoints} + polygonDraftStroke={ + isSlabBuildActive || isCeilingBuildActive ? palette.wallStroke : undefined + } + polygonDraftStrokeWidth={ + isSlabBuildActive || isCeilingBuildActive + ? FLOORPLAN_WALL_STROKE_WIDTH + : undefined + } + unitsPerPixel={floorplanUnitsPerPixel} /> handleSlabMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event) @@ -11577,9 +14880,79 @@ export function FloorplanPanel() { handleSlabVertexPointerDown(nodeId as SlabNode['id'], vertexIndex, event) } palette={palette} + unitsPerPixel={floorplanUnitsPerPixel} vertexHandles={slabVertexHandles} /> + + handleSlabHoleMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event) + } + onVertexDoubleClick={(nodeId, vertexIndex, event) => + handleSlabHoleVertexDoubleClick(nodeId as SlabNode['id'], vertexIndex, event) + } + onVertexPointerDown={(nodeId, vertexIndex, event) => + handleSlabHoleVertexPointerDown(nodeId as SlabNode['id'], vertexIndex, event) + } + palette={palette} + unitsPerPixel={floorplanUnitsPerPixel} + vertexHandles={slabHoleVertexHandles} + /> + + + handleCeilingMidpointPointerDown(nodeId as CeilingNode['id'], edgeIndex, event) + } + onVertexDoubleClick={(nodeId, vertexIndex, event) => + handleCeilingVertexDoubleClick(nodeId as CeilingNode['id'], vertexIndex, event) + } + onVertexPointerDown={(nodeId, vertexIndex, event) => + handleCeilingVertexPointerDown(nodeId as CeilingNode['id'], vertexIndex, event) + } + palette={palette} + unitsPerPixel={floorplanUnitsPerPixel} + vertexHandles={ceilingVertexHandles} + /> + + + handleCeilingHoleMidpointPointerDown( + nodeId as CeilingNode['id'], + edgeIndex, + event, + ) + } + onVertexDoubleClick={(nodeId, vertexIndex, event) => + handleCeilingHoleVertexDoubleClick( + nodeId as CeilingNode['id'], + vertexIndex, + event, + ) + } + onVertexPointerDown={(nodeId, vertexIndex, event) => + handleCeilingHoleVertexPointerDown( + nodeId as CeilingNode['id'], + vertexIndex, + event, + ) + } + palette={palette} + unitsPerPixel={floorplanUnitsPerPixel} + vertexHandles={ceilingHoleVertexHandles} + /> + @@ -11604,7 +14978,7 @@ export function FloorplanPanel() { onCornerHoverChange={setHoveredGuideCorner} onCornerPointerDown={handleGuideCornerPointerDown} rotationModifierPressed={rotationModifierPressed} - showHandles={canInteractWithGuides} + showHandles={canInteractWithGuides && guideUi[selectedGuide.id]?.locked !== true} /> )} @@ -11615,14 +14989,14 @@ export function FloorplanPanel() { cy={toSvgY(cursorPoint[1])} fill={floorplanCursorColor} fillOpacity={0.25} - r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS} + r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS_PX * floorplanUnitsPerPixel} /> )} @@ -11633,7 +15007,7 @@ export function FloorplanPanel() { cy={toSvgY(activeDraftAnchorPoint[1])} fill={palette.anchor} fillOpacity={0.95} - r="0.14" + r={FLOORPLAN_DRAFT_ANCHOR_RADIUS_PX * floorplanUnitsPerPixel} vectorEffect="non-scaling-stroke" /> )} diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 24beb46bf..0e2b5d217 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1287,7 +1287,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea > - +
{widthLabel}
- +
{depthLabel}
- +
diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index c40e40245..c0fa2c941 100755 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -2,15 +2,17 @@ import { type AnyNodeId, + type BuildingNode, type GuideNode, type LevelNode, type ScanNode, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Check, ChevronDown, Plus, Trash2 } from 'lucide-react' +import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react' import { useCallback, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' +import { createLocalGuideImage } from '../../../lib/local-guide-image' import { cn } from '../../../lib/utils' import useEditor, { type GridSnapStep } from '../../../store/use-editor' import { useUploadStore } from '../../../store/use-upload' @@ -59,37 +61,95 @@ function useLevelScans(): ScanNode[] { ) } +function useLowerReferenceLevels(): LevelNode[] { + const levelId = useViewer((s) => s.selection.levelId) + return useScene( + useShallow((state) => { + if (!levelId) return [] as LevelNode[] + const activeLevel = state.nodes[levelId] + if (!activeLevel || activeLevel.type !== 'level') return [] as LevelNode[] + const buildingId = activeLevel.parentId as BuildingNode['id'] | undefined + const building = buildingId ? state.nodes[buildingId] : null + if (!building || building.type !== 'building') return [] as LevelNode[] + + return (building.children ?? []) + .map((id) => state.nodes[id]) + .filter( + (node): node is LevelNode => + node?.type === 'level' && node.id !== levelId && node.level < activeLevel.level, + ) + .sort((a, b) => b.level - a.level) + }), + ) +} + +function getLevelDisplayName(level: LevelNode) { + return level.name || `Level ${level.level}` +} + // ── Shared upload button for dropdowns ────────────────────────────────────── -function UploadButton() { +function UploadButton({ onError }: { onError: (message: string | null) => void }) { const fileInputRef = useRef(null) const levelId = useViewer((s) => s.selection.levelId) + const setSelection = useViewer((s) => s.setSelection) + const setShowGuides = useViewer((s) => s.setShowGuides) + const createNode = useScene((s) => s.createNode) + const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) + const [isAddingGuide, setIsAddingGuide] = useState(false) const handleFileChange = useCallback( - (e: React.ChangeEvent) => { + async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!(file && levelId)) return e.target.value = '' - const { uploadHandler } = useUploadStore.getState() - if (!uploadHandler) return + onError(null) - if (file.size > MAX_FILE_SIZE) return + if (file.size > MAX_FILE_SIZE) { + onError('File is too large. Maximum size is 200 MB.') + return + } const isScan = file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') const isImage = file.type.startsWith('image/') - if (!(isScan || isImage)) return + if (!(isScan || isImage)) { + onError('Upload a .glb/.gltf scan or an image.') + return + } + + if (isImage) { + setIsAddingGuide(true) + try { + const guide = await createLocalGuideImage({ createNode, file, levelId }) + setShowGuides(true) + setSelectedReferenceId(guide.id) + setSelection({ selectedIds: [], zoneId: null }) + } catch { + onError('Could not add that guide image.') + } finally { + setIsAddingGuide(false) + } + return + } - const type = isScan ? 'scan' : 'guide' + const { uploadHandler } = useUploadStore.getState() + if (!uploadHandler) { + onError('Scan upload is unavailable.') + return + } const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0] - if (!projectId) return + if (!projectId) { + onError('Open a project before uploading a scan.') + return + } useUploadStore.getState().clearUpload(levelId) - uploadHandler(projectId, levelId, file, type) + uploadHandler(projectId, levelId, file, 'scan') }, - [levelId], + [createNode, levelId, onError, setSelectedReferenceId, setSelection, setShowGuides], ) return ( @@ -97,6 +157,7 @@ function UploadButton() {
+ {uploadError && ( +
+ {uploadError} +
+ )} + {hasGuides ? (
{guides.map((guide, index) => (
- -

- {guide.name || `Guide image ${index + 1}`} -

+
+ {uploadError && ( +
+ {uploadError} +
+ )} + {hasScans ? (
{scans.map((scan, index) => (
- -

- {scan.name || `Scan ${index + 1}`} -

+ + +
+ + +
+
+ + + +
+

Reference floor

+ {selectedLevelName && ( +

{selectedLevelName}

+ )} +
+ +
+ + {hasLowerLevels ? ( + <> +
+ {lowerLevels.map((level, index) => { + const isSelected = referenceFloorOffset === index + 1 + const levelName = getLevelDisplayName(level) + return ( + + ) + })} +
+ + + + ) : ( +
+ No lower floor available. +
+ )} +
+
+ + ) +} + // ── Main ViewToggles ──────────────────────────────────────────────────────── export function ViewToggles() { @@ -476,6 +767,8 @@ export function ViewToggles() { {/* Guides (toggle + dropdown) */} + +
) } @@ -487,6 +780,7 @@ export function SecondaryToggles() { +
) } diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index e2bb41d86..12a1f9548 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -15,6 +15,7 @@ interface SliderControlProps { step?: number className?: string unit?: string + restoreOnCommit?: boolean } function stepPrecision(s: number): number { @@ -56,6 +57,7 @@ export function SliderControl({ step = 1, className, unit = '', + restoreOnCommit = true, }: SliderControlProps) { const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) @@ -161,7 +163,10 @@ export function SliderControl({ const newValue = clamp( Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))), ) - onChange(newValue) + if (newValue !== valueRef.current) { + valueRef.current = newValue + onChange(newValue) + } }, [step, clamp, onChange], ) @@ -175,7 +180,7 @@ export function SliderControl({ setIsDragging(false) e.currentTarget.releasePointerCapture(e.pointerId) - if (originValue !== finalVal) { + if (originValue !== finalVal && restoreOnCommit) { onChange(originValue) useScene.temporal.getState().resume() onChange(finalVal) @@ -185,7 +190,7 @@ export function SliderControl({ onCommit?.(finalVal) } }, - [onChange, onCommit], + [onChange, onCommit, restoreOnCommit], ) const handleValueClick = useCallback(() => { diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index a11ff7be5..c348a3037 100755 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -1,5 +1,23 @@ 'use client' +import { + closestCenter, + DndContext, + type DragEndEvent, + type DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { type AnyNode, type AnyNodeId, @@ -8,8 +26,15 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Copy, MoreVertical, Plus, Trash2 } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' +import { + type ButtonHTMLAttributes, + type CSSProperties, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import { useShallow } from 'zustand/react/shallow' import { buildLevelDuplicateCreateOps, @@ -96,12 +121,18 @@ function LevelInlineRename({ function LevelRow({ level, isSelected, + isDragging, + dragHandleProps, + dragHandleRef, onSelect, onDuplicate, onRequestDelete, }: { level: LevelNode isSelected: boolean + isDragging?: boolean + dragHandleProps?: ButtonHTMLAttributes + dragHandleRef?: (element: HTMLButtonElement | null) => void onSelect: () => void onDuplicate: (preset?: LevelDuplicatePreset) => void onRequestDelete: () => void @@ -121,13 +152,32 @@ function LevelRow({
+ + + {!draggingLevelId && ( + + )} {/* Floating + at bottom edge */} - + {!draggingLevelId && ( + + )} {/* Level list */} -
- {reversedLevels.map((level, i) => { - const isSelected = level.id === levelId - const sortedIndex = levels.indexOf(level) - const showGapBelow = i < reversedLevels.length - 1 - - return ( -
- handleDuplicateLevel(level, preset)} - onRequestDelete={() => setDeletingLevel(level)} - onSelect={() => - setSelection( - resolvedBuildingId - ? { buildingId: resolvedBuildingId, levelId: level.id } - : { levelId: level.id }, - ) - } - /> - - {showGapBelow && ( - - )} -
- ) - })} -
+ + +
+ {reversedLevels.map((level, i) => { + const isSelected = level.id === levelId + const sortedIndex = levels.indexOf(level) + const showGapBelow = i < reversedLevels.length - 1 + + return ( +
+ handleDuplicateLevel(level, preset)} + onRequestDelete={() => setDeletingLevel(level)} + onSelect={() => + setSelection( + resolvedBuildingId + ? { buildingId: resolvedBuildingId, levelId: level.id } + : { levelId: level.id }, + ) + } + /> + + {showGapBelow && !draggingLevelId && ( + + )} +
+ ) + })} +
+
+
diff --git a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx index f44ede2db..f26ad5271 100755 --- a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx +++ b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx @@ -384,7 +384,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ tags: ['chair', 'seating', 'ergonomic', 'swivel', 'office', 'desk', 'mesh', 'leather', 'modern', 'task', 'workspace', 'professional', 'computer'], thumbnail: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/office-chair/thumbnail.png', src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/office-chair/model.glb', - floorPlanUrl: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/office-chair/floor-plan.png', + floorPlanUrl: '/items/office-chair/floor-plan.svg', dimensions: [1, 1.2, 1], offset: [0.01, 0, 0.03], rotation: [0, 0, 0], @@ -438,7 +438,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ tags: ['floor', 'seating'], thumbnail: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/sofa/thumbnail.png', src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/sofa/model.glb', - floorPlanUrl: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/sofa/floor-plan.png', + floorPlanUrl: '/items/sofa/floor-plan.svg', dimensions: [2.5, 0.8, 1.5], offset: [0, 0, 0.04], rotation: [0, 0, 0], diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index e02f13c75..363a859ce 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -9,7 +9,7 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { usePresetsAdapter } from '../../../contexts/presets-context' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' @@ -22,12 +22,32 @@ import { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' import { PresetsPopover } from './presets/presets-popover' +function isSameDoorValue(current: unknown, next: unknown): boolean { + if (typeof current === 'number' && typeof next === 'number') { + return Math.abs(current - next) < 1e-6 + } + + if (Array.isArray(current) && Array.isArray(next)) { + return ( + current.length === next.length && + current.every((value, index) => isSameDoorValue(value, next[index])) + ) + } + + return Object.is(current, next) +} + export function DoorPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const previewRef = useRef<{ + id: AnyNodeId + key: keyof DoorNode + value: unknown + } | null>(null) const adapter = usePresetsAdapter() @@ -37,10 +57,57 @@ export function DoorPanel() { const handleUpdate = useCallback( (updates: Partial) => { - if (!selectedId) return + if (!(selectedId && node)) return + const hasChange = Object.entries(updates).some(([key, value]) => { + const currentValue = node[key as keyof DoorNode] + return !isSameDoorValue(currentValue, value) + }) + if (!hasChange) return + updateNode(selectedId as AnyNode['id'], updates) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) }, + [selectedId, node, updateNode], + ) + + const previewDoorUpdate = useCallback( + (key: K, value: DoorNode[K]) => { + if (!selectedId) return + const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] + if (liveNode?.type !== 'door') return + + if (!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)) { + previewRef.current = { + id: selectedId as AnyNodeId, + key, + value: liveNode[key], + } + } + + if (isSameDoorValue(liveNode[key], value)) return + + ;(liveNode as DoorNode)[key] = value + useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + }, + [selectedId], + ) + + const commitDoorPreview = useCallback( + (key: K, value: DoorNode[K]) => { + if (!selectedId) return + + const scene = useScene.getState() + const liveNode = scene.nodes[selectedId as AnyNodeId] + const preview = previewRef.current + if (liveNode?.type === 'door' && preview?.id === selectedId && preview.key === key) { + ;(liveNode as DoorNode)[key] = preview.value as DoorNode[K] + scene.dirtyNodes.add(selectedId as AnyNodeId) + } + previewRef.current = null + + updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) + scene.dirtyNodes.add(selectedId as AnyNodeId) + }, [selectedId, updateNode], ) @@ -85,9 +152,10 @@ export function DoorPanel() { }, [node, setMovingNode, setSelection]) const setSegmentHeightRatio = (segIdx: number, newVal: number) => { - const numSegs = node?.segments.length - const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0) - const normH = node?.segments.map((s) => s.heightRatio / totalH) + if (!node) return + const numSegs = node.segments.length + const totalH = node.segments.reduce((sum, s) => sum + s.heightRatio, 0) + const normH = node.segments.map((s) => s.heightRatio / totalH) const clamped = Math.max(0.05, Math.min(0.95, newVal)) const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1 const delta = clamped - normH[segIdx]! @@ -97,7 +165,7 @@ export function DoorPanel() { if (i === neighborIdx) return neighborVal return v }) - const updated = node?.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! })) + const updated = node.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! })) handleUpdate({ segments: updated }) } @@ -131,6 +199,13 @@ export function DoorPanel() { height: node.height, frameThickness: node.frameThickness, frameDepth: node.frameDepth, + openingKind: node.openingKind, + openingShape: node.openingShape, + openingRadiusMode: node.openingRadiusMode ?? 'all', + openingTopRadii: node.openingTopRadii ?? [0.15, 0.15], + cornerRadius: node.cornerRadius, + archHeight: node.archHeight, + openingRevealRadius: node.openingRevealRadius, contentPadding: node.contentPadding, hingesSide: node.hingesSide, swingDirection: node.swingDirection, @@ -177,6 +252,24 @@ export function DoorPanel() { const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0) const normHeights = node.segments.map((seg) => seg.heightRatio / hSum) + const isOpening = node.openingKind === 'opening' + const openingShape = node.openingShape ?? 'rectangle' + const openingRadiusMode = node.openingRadiusMode ?? 'all' + const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15] + const cornerRadius = node.cornerRadius ?? 0.15 + const archHeight = node.archHeight ?? 0.45 + const openingRevealRadius = node.openingRevealRadius ?? 0.025 + const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height)) + + const setOpeningTopRadius = (index: number, value: number, commit = false) => { + const next = [...openingTopRadii] as [number, number] + next[index] = value + if (commit) { + commitDoorPreview('openingTopRadii', next) + } else { + previewDoorUpdate('openingTopRadii', next) + } + } return (
+ +
+ + handleUpdate( + v === 'opening' + ? { + openingKind: v, + openingShape, + openingRadiusMode, + openingTopRadii, + cornerRadius, + archHeight, + openingRevealRadius, + } + : { openingKind: v }, + ) + } + options={[ + { label: 'Door', value: 'door' }, + { label: 'Opening', value: 'opening' }, + ]} + value={node.openingKind} + /> +
+
+ -
- } - label="Flip Side" - onClick={handleFlip} - /> -
+ {!isOpening && ( +
+ } + label="Flip Side" + onClick={handleFlip} + /> +
+ )}
@@ -238,6 +360,7 @@ export function DoorPanel() { min={0.5} onChange={(v) => handleUpdate({ width: v })} precision={2} + restoreOnCommit={false} step={0.05} unit="m" value={Math.round(node.width * 100) / 100} @@ -250,12 +373,111 @@ export function DoorPanel() { handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] }) } precision={2} + restoreOnCommit={false} step={0.05} unit="m" value={Math.round(node.height * 100) / 100} /> + {isOpening && ( + +
+ + handleUpdate({ + openingShape: v, + ...(v === 'rounded' + ? { openingRadiusMode, openingTopRadii, cornerRadius, openingRevealRadius } + : {}), + ...(v === 'arch' ? { archHeight } : {}), + }) + } + options={[ + { label: 'Rect', value: 'rectangle' }, + { label: 'Rounded', value: 'rounded' }, + { label: 'Arch', value: 'arch' }, + ]} + value={openingShape} + /> +
+ {openingShape === 'rounded' && ( + <> +
+ + handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] }) + } + options={[ + { label: 'All', value: 'all' }, + { label: 'Individual', value: 'individual' }, + ]} + value={openingRadiusMode} + /> +
+ {openingRadiusMode === 'all' ? ( + previewDoorUpdate('cornerRadius', v)} + onCommit={(v) => commitDoorPreview('cornerRadius', v)} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + ) : ( + <> + {[ + ['Top Left', 0], + ['Top Right', 1], + ].map(([label, index]) => ( + setOpeningTopRadius(index as number, v)} + onCommit={(v) => setOpeningTopRadius(index as number, v, true)} + precision={2} + step={0.05} + unit="m" + value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100} + /> + ))} + + )} + previewDoorUpdate('openingRevealRadius', v)} + onCommit={(v) => commitDoorPreview('openingRevealRadius', v)} + precision={3} + step={0.005} + unit="m" + value={Math.round(openingRevealRadius * 1000) / 1000} + /> + + )} + {openingShape === 'arch' && ( + handleUpdate({ archHeight: v })} + precision={2} + step={0.05} + unit="m" + value={Math.round(archHeight * 100) / 100} + /> + )} +
+ )} + + {!isOpening && ( + <> + + )} + } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/reference-panel.tsx b/packages/editor/src/components/ui/panels/reference-panel.tsx index a1c5bf1e4..57b771c8d 100644 --- a/packages/editor/src/components/ui/panels/reference-panel.tsx +++ b/packages/editor/src/components/ui/panels/reference-panel.tsx @@ -1,21 +1,47 @@ 'use client' -import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core' -import { Box, Image as ImageIcon } from 'lucide-react' -import { useCallback } from 'react' +import { + type AnyNode, + emitter, + type GuideNode, + loadAssetUrl, + saveAsset, + type ScanNode, + useScene, +} from '@pascal-app/core' +import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { getGuideImageName } from '../../../lib/local-guide-image' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' type ReferenceNode = ScanNode | GuideNode +function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) { + const reference = guide.scaleReference + if (!reference) { + return 'Uncalibrated' + } + + return `${scaleReferenceVisible ? 'Scaled' : 'Scaled (hidden)'} · ${reference.label}` +} + export function ReferencePanel() { const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) + const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined)) + const setGuideLocked = useEditor((s) => s.setGuideLocked) + const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible) + const clearGuideUi = useEditor((s) => s.clearGuideUi) const updateNode = useScene((s) => s.updateNode) + const deleteNode = useScene((s) => s.deleteNode) + const replaceInputRef = useRef(null) + const [isReplacing, setIsReplacing] = useState(false) + const [replaceError, setReplaceError] = useState(null) + const [isAssetMissing, setIsAssetMissing] = useState(false) const node = useScene((s) => selectedReferenceId @@ -35,17 +61,224 @@ export function ReferencePanel() { setSelectedReferenceId(null) }, [setSelectedReferenceId]) + const handleReplaceFile = useCallback( + async (file: File) => { + if (!(selectedReferenceId && node?.type === 'guide')) { + return + } + + if (!file.type.startsWith('image/')) { + setReplaceError('Choose a PNG, JPEG, or WebP image.') + return + } + + setIsReplacing(true) + setReplaceError(null) + + try { + const assetUrl = await saveAsset(file) + updateNode(selectedReferenceId as AnyNode['id'], { + name: getGuideImageName(file.name), + url: assetUrl, + scaleReference: null, + } as Partial) + setGuideScaleReferenceVisible(selectedReferenceId, true) + } catch { + setReplaceError('Could not replace that image.') + } finally { + setIsReplacing(false) + } + }, + [node?.type, selectedReferenceId, setGuideScaleReferenceVisible, updateNode], + ) + + const handleDeleteGuide = useCallback(() => { + if (!(selectedReferenceId && node?.type === 'guide')) { + return + } + + deleteNode(selectedReferenceId as AnyNode['id']) + emitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] }) + clearGuideUi(selectedReferenceId) + setSelectedReferenceId(null) + }, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId]) + + const handleStartScale = useCallback(() => { + if (node?.type !== 'guide') { + return + } + + emitter.emit('guide:set-reference-scale', { guideId: node.id }) + }, [node]) + + const handleCancelScale = useCallback(() => { + emitter.emit('guide:cancel-reference-scale') + }, []) + + useEffect(() => { + if (node?.type !== 'guide' || !node.url.startsWith('asset://')) { + setIsAssetMissing(false) + return + } + + let cancelled = false + loadAssetUrl(node.url).then((resolvedUrl) => { + if (!cancelled) { + setIsAssetMissing(!resolvedUrl) + } + }) + + return () => { + cancelled = true + } + }, [node]) + if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null const isScan = node.type === 'scan' + const guideLocked = !isScan && guideUi?.locked === true + const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false + const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null return ( + {!isScan && ( + <> + + { + const file = event.currentTarget.files?.[0] + event.currentTarget.value = '' + if (file) { + void handleReplaceFile(file) + } + }} + ref={replaceInputRef} + type="file" + /> + + + } + label={isReplacing ? 'Replacing...' : 'Replace'} + onClick={() => replaceInputRef.current?.click()} + disabled={isReplacing} + /> + } + label="Delete" + onClick={handleDeleteGuide} + className="text-destructive hover:bg-destructive/10" + /> + + + + + ) : ( + + ) + } + label={node.visible === false ? 'Show' : 'Hide'} + onClick={() => handleUpdate({ visible: node.visible === false })} + /> + + ) : ( + + ) + } + label={guideLocked ? 'Unlock' : 'Lock'} + onClick={() => setGuideLocked(node.id, !guideLocked)} + /> + + + {replaceError && ( +
+ {replaceError} +
+ )} + + {isAssetMissing && ( +
+ Overlay image unavailable. Replace the image to restore it. +
+ )} +
+ + +
+ + {scaleStatus} +
+ + + + + + + + { + if (!node.scaleReference) return + setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible) + }} + /> + handleUpdate({ scaleReference: null } as Partial)} + /> + +
+ + + + } + label="Center" + onClick={() => + handleUpdate({ + position: [0, node.position[1], 0], + } as Partial) + } + /> + } + label="Reset Rotation" + onClick={() => + handleUpdate({ + rotation: [node.rotation[0], 0, node.rotation[2]], + } as Partial) + } + /> + + + } + label="Reset Image Scale" + onClick={() => handleUpdate({ scale: 1 } as Partial)} + /> + + + + )} + + isSameWindowValue(value, next[index])) + ) + } + + return Object.is(current, next) +} + +function getMaxSharedWindowRadius(width: number, height: number) { + return Math.max(0, Math.min(width / 2, height / 2)) +} + +function normalizeWindowCornerRadii( + radii: [number, number, number, number], + width: number, + height: number, +): [number, number, number, number] { + const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number] + const scale = Math.min( + 1, + Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6), + Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6), + Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6), + Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6), + ) + + if (scale >= 1) return next + + return next.map((radius) => radius * scale) as [number, number, number, number] +} + +function isSameRadiusTuple( + current: [number, number, number, number], + next: [number, number, number, number], +) { + return current.every((value, index) => Math.abs(value - next[index]) < 1e-6) +} + export function WindowPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const previewRef = useRef<{ + id: AnyNodeId + key: keyof WindowNode + value: unknown + } | null>(null) const adapter = usePresetsAdapter() @@ -36,10 +87,59 @@ export function WindowPanel() { const handleUpdate = useCallback( (updates: Partial) => { - if (!selectedId) return + if (!(selectedId && node)) return + const hasChange = Object.entries(updates).some(([key, value]) => { + const currentValue = node[key as keyof WindowNode] + return !isSameWindowValue(currentValue, value) + }) + if (!hasChange) return + updateNode(selectedId as AnyNode['id'], updates) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) }, + [selectedId, node, updateNode], + ) + + const previewWindowUpdate = useCallback( + (key: K, value: WindowNode[K]) => { + if (!selectedId) return + const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] + if (liveNode?.type !== 'window') return + + if ( + !(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key) + ) { + previewRef.current = { + id: selectedId as AnyNodeId, + key, + value: liveNode[key], + } + } + + if (isSameWindowValue(liveNode[key], value)) return + + ;(liveNode as WindowNode)[key] = value + useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + }, + [selectedId], + ) + + const commitWindowPreview = useCallback( + (key: K, value: WindowNode[K]) => { + if (!selectedId) return + + const scene = useScene.getState() + const liveNode = scene.nodes[selectedId as AnyNodeId] + const preview = previewRef.current + if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) { + ;(liveNode as WindowNode)[key] = preview.value as WindowNode[K] + scene.dirtyNodes.add(selectedId as AnyNodeId) + } + previewRef.current = null + + updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) + scene.dirtyNodes.add(selectedId as AnyNodeId) + }, [selectedId, updateNode], ) @@ -84,6 +184,13 @@ export function WindowPanel() { height: node.height, frameThickness: node.frameThickness, frameDepth: node.frameDepth, + openingKind: node.openingKind, + openingShape: node.openingShape, + openingRadiusMode: node.openingRadiusMode ?? 'all', + openingCornerRadii: [...(node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15])], + cornerRadius: node.cornerRadius, + archHeight: node.archHeight, + openingRevealRadius: node.openingRevealRadius, columnRatios: [...node.columnRatios], rowRatios: [...node.rowRatios], columnDividerThickness: node.columnDividerThickness, @@ -105,6 +212,13 @@ export function WindowPanel() { height: node.height, frameThickness: node.frameThickness, frameDepth: node.frameDepth, + openingKind: node.openingKind, + openingShape: node.openingShape, + openingRadiusMode: node.openingRadiusMode ?? 'all', + openingCornerRadii: node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15], + cornerRadius: node.cornerRadius, + archHeight: node.archHeight, + openingRevealRadius: node.openingRevealRadius, columnRatios: node.columnRatios, rowRatios: node.rowRatios, columnDividerThickness: node.columnDividerThickness, @@ -151,6 +265,58 @@ export function WindowPanel() { const rowSum = node.rowRatios.reduce((a, b) => a + b, 0) const normCols = node.columnRatios.map((r) => r / colSum) const normRows = node.rowRatios.map((r) => r / rowSum) + const isOpening = node.openingKind === 'opening' + const openingShape = node.openingShape ?? 'rectangle' + const openingRadiusMode = node.openingRadiusMode ?? 'all' + const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] + const cornerRadius = node.cornerRadius ?? 0.15 + const archHeight = node.archHeight ?? 0.35 + const openingRevealRadius = node.openingRevealRadius ?? 0.025 + const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height)) + + const getDimensionUpdates = (updates: Partial>) => { + const nextWidth = updates.width ?? node.width + const nextHeight = updates.height ?? node.height + const nextUpdates: Partial = { ...updates } + + if (openingShape === 'rounded') { + if (openingRadiusMode === 'individual') { + const currentRadii = openingCornerRadii as [number, number, number, number] + const nextRadii = normalizeWindowCornerRadii( + openingCornerRadii as [number, number, number, number], + nextWidth, + nextHeight, + ) + if (!isSameRadiusTuple(currentRadii, nextRadii)) { + nextUpdates.openingCornerRadii = nextRadii + } + } else { + const nextRadius = Math.min(Math.max(cornerRadius, 0), getMaxSharedWindowRadius(nextWidth, nextHeight)) + if (Math.abs(nextRadius - cornerRadius) > 1e-6) { + nextUpdates.cornerRadius = nextRadius + } + } + } + + if (openingShape === 'arch') { + const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05)) + if (Math.abs(nextArchHeight - archHeight) > 1e-6) { + nextUpdates.archHeight = nextArchHeight + } + } + + return nextUpdates + } + + const setOpeningCornerRadius = (index: number, value: number, commit = false) => { + const next = [...openingCornerRadii] as [number, number, number, number] + next[index] = value + if (commit) { + commitWindowPreview('openingCornerRadii', next) + } else { + previewWindowUpdate('openingCornerRadii', next) + } + } const setColumnRatio = (index: number, newVal: number) => { const clamped = Math.max(0.05, Math.min(0.95, newVal)) @@ -206,6 +372,31 @@ export function WindowPanel() {
+ + + handleUpdate({ + openingKind: value as WindowNode['openingKind'], + ...(value === 'opening' + ? { + openingShape, + openingRadiusMode, + openingCornerRadii, + cornerRadius, + archHeight, + openingRevealRadius, + } + : {}), + }) + } + options={[ + { value: 'window', label: 'Window' }, + { value: 'opening', label: 'Opening' }, + ]} + value={node.openingKind ?? 'window'} + /> + + -
- } - label="Flip Side" - onClick={handleFlip} - /> -
+ {!isOpening && ( +
+ } + label="Flip Side" + onClick={handleFlip} + /> +
+ )}
handleUpdate({ width: v })} + onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))} precision={2} + restoreOnCommit={false} step={0.1} unit="m" value={Math.round(node.width * 100) / 100} @@ -254,157 +448,252 @@ export function WindowPanel() { handleUpdate({ height: v })} + onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))} precision={2} + restoreOnCommit={false} step={0.1} unit="m" value={Math.round(node.height * 100) / 100} /> - - handleUpdate({ frameThickness: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.frameThickness * 1000) / 1000} - /> - handleUpdate({ frameDepth: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.frameDepth * 1000) / 1000} - /> - - - - { - const n = Math.max(1, Math.min(8, Math.round(v))) - handleUpdate({ columnRatios: Array(n).fill(1 / n) }) - }} - precision={0} - step={1} - value={numCols} - /> - { - const n = Math.max(1, Math.min(8, Math.round(v))) - handleUpdate({ rowRatios: Array(n).fill(1 / n) }) - }} - precision={0} - step={1} - value={numRows} - /> - - {numCols > 1 && ( -
-
- Col Widths -
- {normCols.map((ratio, i) => ( - setColumnRatio(i, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(ratio * 100 * 10) / 10} + {isOpening && ( + + + handleUpdate({ openingShape: value as WindowNode['openingShape'] }) + } + options={[ + { value: 'rectangle', label: 'Rect' }, + { value: 'rounded', label: 'Rounded' }, + { value: 'arch', label: 'Arch' }, + ]} + value={openingShape} + /> + {openingShape === 'rounded' && ( +
+ + handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) + } + options={[ + { value: 'all', label: 'All' }, + { value: 'individual', label: 'Individual' }, + ]} + value={openingRadiusMode} /> - ))} -
+ {openingRadiusMode === 'all' ? ( + previewWindowUpdate('cornerRadius', value)} + onCommit={(value) => commitWindowPreview('cornerRadius', value)} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + ) : ( + <> + {[ + ['Top Left', 0], + ['Top Right', 1], + ['Bottom Right', 2], + ['Bottom Left', 3], + ].map(([label, index]) => ( + setOpeningCornerRadius(index as number, value)} + onCommit={(value) => setOpeningCornerRadius(index as number, value, true)} + precision={2} + step={0.05} + unit="m" + value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100} + /> + ))} + + )} handleUpdate({ columnDividerThickness: v })} + label="Reveal Radius" + max={0.08} + min={0} + onChange={(value) => previewWindowUpdate('openingRevealRadius', value)} + onCommit={(value) => commitWindowPreview('openingRevealRadius', value)} precision={3} - step={0.01} + step={0.005} unit="m" - value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} + value={Math.round(openingRevealRadius * 1000) / 1000} />
-
- )} - - {numRows > 1 && ( -
-
- Row Heights -
- {normRows.map((ratio, i) => ( + )} + {openingShape === 'arch' && ( +
setRowRatio(i, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(ratio * 100 * 10) / 10} - /> - ))} -
- handleUpdate({ rowDividerThickness: v })} - precision={3} - step={0.01} + label="Arch Height" + max={Math.max(0.05, node.height)} + min={0.05} + onChange={(value) => handleUpdate({ archHeight: value })} + precision={2} + step={0.05} unit="m" - value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} + value={Math.round(archHeight * 100) / 100} />
-
- )} - + )} + + )} - - handleUpdate({ sill: checked })} - /> - {node.sill && ( -
+ {!isOpening && ( + <> + handleUpdate({ sillDepth: v })} + onChange={(v) => handleUpdate({ frameThickness: v })} precision={3} step={0.01} unit="m" - value={Math.round(node.sillDepth * 1000) / 1000} + value={Math.round(node.frameThickness * 1000) / 1000} /> handleUpdate({ sillThickness: v })} + onChange={(v) => handleUpdate({ frameDepth: v })} precision={3} step={0.01} unit="m" - value={Math.round(node.sillThickness * 1000) / 1000} + value={Math.round(node.frameDepth * 1000) / 1000} /> -
- )} -
+ + + + { + const n = Math.max(1, Math.min(8, Math.round(v))) + handleUpdate({ columnRatios: Array(n).fill(1 / n) }) + }} + precision={0} + step={1} + value={numCols} + /> + { + const n = Math.max(1, Math.min(8, Math.round(v))) + handleUpdate({ rowRatios: Array(n).fill(1 / n) }) + }} + precision={0} + step={1} + value={numRows} + /> + + {numCols > 1 && ( +
+
+ Col Widths +
+ {normCols.map((ratio, i) => ( + setColumnRatio(i, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(ratio * 100 * 10) / 10} + /> + ))} +
+ handleUpdate({ columnDividerThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} + /> +
+
+ )} + + {numRows > 1 && ( +
+
+ Row Heights +
+ {normRows.map((ratio, i) => ( + setRowRatio(i, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(ratio * 100 * 10) / 10} + /> + ))} +
+ handleUpdate({ rowDividerThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} + /> +
+
+ )} +
+ + + handleUpdate({ sill: checked })} + /> + {node.sill && ( +
+ handleUpdate({ sillDepth: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round(node.sillDepth * 1000) / 1000} + /> + handleUpdate({ sillThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round(node.sillThickness * 1000) / 1000} + /> +
+ )} +
+ + )} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 24742881a..b616655e2 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -33,10 +33,13 @@ import { PopoverTrigger, } from './../../../../../components/ui/primitives/popover' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' +import { createLocalGuideImage } from './../../../../../lib/local-guide-image' + import { buildLevelDuplicateCreateOps, type LevelDuplicatePreset, } from './../../../../../lib/level-duplication' + import { cn } from './../../../../../lib/utils' import useEditor from './../../../../../store/use-editor' import { useUploadStore } from '../../../../../store/use-upload' @@ -400,7 +403,10 @@ const LevelReferences = memo(function LevelReferences({ onUploadAsset, onDeleteAsset, }: LevelReferencesProps) { + const createNode = useScene((s) => s.createNode) const deleteNode = useScene((s) => s.deleteNode) + const setSelection = useViewer((s) => s.setSelection) + const setShowGuides = useViewer((s) => s.setShowGuides) const references = useScene( useShallow((s) => Object.values(s.nodes).filter( @@ -423,19 +429,27 @@ const LevelReferences = memo(function LevelReferences({ const scanInputRef = useRef(null) - const handleAddAsset = (e: React.ChangeEvent) => { + const handleAddAsset = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return e.target.value = '' - if (!projectId) { - useUploadStore.getState().startUpload(levelId, 'scan', file.name) - useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.') + // Auto-detect type based on file extension/mime type + const isScan = + file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') + const isImage = file.type.startsWith('image/') + const type = isScan ? 'scan' : 'guide' + + if (!(isScan || isImage)) { + useUploadStore.getState().startUpload(levelId, type, file.name) + useUploadStore + .getState() + .setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.') return } if (file.size > MAX_FILE_SIZE) { - useUploadStore.getState().startUpload(levelId, 'scan', file.name) + useUploadStore.getState().startUpload(levelId, type, file.name) useUploadStore .getState() .setError( @@ -445,21 +459,29 @@ const LevelReferences = memo(function LevelReferences({ return } - // Auto-detect type based on file extension/mime type - const isScan = - file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') - const isImage = file.type.startsWith('image/') + if (isImage) { + useUploadStore.getState().startUpload(levelId, 'guide', file.name) + useUploadStore.getState().setStatus(levelId, 'uploading') + + try { + const guide = await createLocalGuideImage({ createNode, file, levelId }) + setShowGuides(true) + setSelectedReferenceId(guide.id) + setSelection({ selectedIds: [], zoneId: null }) + useUploadStore.getState().setResult(levelId, guide.url) + window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600) + } catch { + useUploadStore.getState().setError(levelId, 'Could not add that guide image.') + } + return + } - if (!(isScan || isImage)) { + if (!projectId) { useUploadStore.getState().startUpload(levelId, 'scan', file.name) - useUploadStore - .getState() - .setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.') + useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.') return } - const type = isScan ? 'scan' : 'guide' - clearUpload(levelId) onUploadAsset?.(projectId, levelId, file, type) } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx index 80c815ecc..cf5848e37 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx @@ -54,7 +54,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({ alt="" className="object-contain" height={14} - src="/icons/spawn-point.svg" + src="/icons/site.png" width={14} /> } diff --git a/packages/editor/src/components/ui/viewer-toolbar.tsx b/packages/editor/src/components/ui/viewer-toolbar.tsx index 53fe873ee..605309066 100644 --- a/packages/editor/src/components/ui/viewer-toolbar.tsx +++ b/packages/editor/src/components/ui/viewer-toolbar.tsx @@ -2,7 +2,18 @@ import { Icon as IconifyIcon } from '@iconify/react' import { useViewer } from '@pascal-app/viewer' -import { Check, ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react' +import { + Check, + ChevronsLeft, + ChevronsRight, + Columns2, + Eye, + EyeOff, + Footprints, + Grid2X2, + Moon, + Sun, +} from 'lucide-react' import { useCallback } from 'react' import { cn } from '../../lib/utils' import useEditor from '../../store/use-editor' @@ -271,6 +282,35 @@ function GridSnapToggle() { ) } +function GridVisibilityToggle() { + const showGrid = useViewer((s) => s.showGrid) + const setShowGrid = useViewer((s) => s.setShowGrid) + + return ( + + + + + Grid: {showGrid ? 'Visible' : 'Hidden'} + + ) +} + // ── Wall mode toggle ──────────────────────────────────────────────────────── const wallModeOrder = ['cutaway', 'up', 'down'] as const @@ -383,6 +423,7 @@ export function ViewerToolbarRight() { +
diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 328116ad7..dd7623865 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -5,6 +5,8 @@ import { runRedo, runUndo } from '../lib/history' import { sfxEmitter } from '../lib/sfx-bus' import useEditor from '../store/use-editor' +const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 + // Tools call this in their onCancel handler when they have an active mid-action to cancel, // so that the global Escape handler knows not to also switch to select mode. let _toolCancelConsumed = false @@ -145,10 +147,21 @@ export const useKeyboard = ({ } } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) { // Rotate selected node clockwise if it supports rotation (items, roofs, etc.) + // Doors use R to toggle their leaf open/closed around the hinge. const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] - if (node && 'rotation' in node) { + if (node?.type === 'door') { + e.preventDefault() + if (node.openingKind !== 'opening') { + const currentSwingAngle = node.swingAngle ?? 0 + useScene.getState().updateNode(node.id, { + swingAngle: + currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + }) + sfxEmitter.emit('sfx:item-rotate') + } + } else if (node && 'rotation' in node) { e.preventDefault() const ROTATION_STEP = Math.PI / 4 @@ -168,7 +181,13 @@ export const useKeyboard = ({ const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] - if (node && 'rotation' in node) { + if (node?.type === 'door') { + e.preventDefault() + if (node.openingKind !== 'opening') { + useScene.getState().updateNode(node.id, { swingAngle: 0 }) + sfxEmitter.emit('sfx:item-rotate') + } + } else if (node && 'rotation' in node) { e.preventDefault() const ROTATION_STEP = Math.PI / 4 diff --git a/packages/editor/src/lib/floorplan/items.ts b/packages/editor/src/lib/floorplan/items.ts index 8f999e56c..40d290a54 100644 --- a/packages/editor/src/lib/floorplan/items.ts +++ b/packages/editor/src/lib/floorplan/items.ts @@ -139,6 +139,21 @@ export function buildFloorplanItemEntry( return null } + const dimensionPolygon = getItemDimensionPolygon(item, transform) + const [width, , depth] = getScaledDimensions(item) + if (shouldUseDimensionFloorplanFootprint(item)) { + return { + dimensionPolygon, + item, + polygon: dimensionPolygon, + usesRealMesh: true, + center: transform.position, + rotation: transform.rotation, + width, + depth, + } + } + const object = sceneRegistry.nodes.get(item.id) const realMeshPolygon = object ? getRealMeshFloorplanPolygon(transform, object) @@ -147,9 +162,6 @@ export function buildFloorplanItemEntry( return null } - const dimensionPolygon = getItemDimensionPolygon(item, transform) - const [width, , depth] = getScaledDimensions(item) - return { dimensionPolygon, item, @@ -167,6 +179,29 @@ type Point = { y: number } +const DIMENSION_FOOTPRINT_ASSET_IDS = new Set(['tree', 'fir-tree', 'palm', 'bush']) +const DIMENSION_FOOTPRINT_TAGS = new Set([ + 'botanical', + 'foliage', + 'greenery', + 'plant', + 'tree', + 'vegetation', +]) + +function shouldUseDimensionFloorplanFootprint(item: ItemNode) { + const asset = item.asset + if (asset.category !== 'outdoor') { + return false + } + + if (DIMENSION_FOOTPRINT_ASSET_IDS.has(asset.id)) { + return true + } + + return asset.tags?.some((tag) => DIMENSION_FOOTPRINT_TAGS.has(tag.toLowerCase())) ?? false +} + function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] { const [width, , depth] = getScaledDimensions(item) const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0 diff --git a/packages/editor/src/lib/floorplan/stairs.ts b/packages/editor/src/lib/floorplan/stairs.ts index 3f06d171f..68188a6d9 100644 --- a/packages/editor/src/lib/floorplan/stairs.ts +++ b/packages/editor/src/lib/floorplan/stairs.ts @@ -200,8 +200,7 @@ function getFloorplanArcPoint(center: Point2D, radius: number, angle: number): P function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { const stairType = stair.stairType ?? 'straight' - const baseSweepAngle = - stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2) + const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2) if (Math.abs(baseSweepAngle) >= Math.PI * 2) { return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001) @@ -210,11 +209,29 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { return baseSweepAngle } +function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { + if ( + (stair.stairType ?? 'straight') !== 'spiral' || + (stair.topLandingMode ?? 'none') !== 'integrated' + ) { + return 0 + } + + const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9) + const width = Math.max(stair.width ?? 1, 0.4) + const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8)) + + return ( + Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) * + Math.sign(sweepAngle || 1) + ) +} + function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] { const stairType = stair.stairType ?? 'straight' const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair) - const startAngle = stair.rotation - sweepAngle / 2 - const endAngle = startAngle + sweepAngle + const startAngle = -stair.rotation - sweepAngle / 2 + const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle) const center = { x: stair.position[0], y: stair.position[2], diff --git a/packages/editor/src/lib/local-guide-image.ts b/packages/editor/src/lib/local-guide-image.ts new file mode 100644 index 000000000..7dfe17751 --- /dev/null +++ b/packages/editor/src/lib/local-guide-image.ts @@ -0,0 +1,42 @@ +import { + type AnyNodeId, + GuideNode, + type GuideNode as GuideNodeType, + saveAsset, +} from '@pascal-app/core' + +export function getGuideImageName(filename: string) { + const trimmed = filename.trim() + if (!trimmed) { + return 'Guide image' + } + + const dotIndex = trimmed.lastIndexOf('.') + return dotIndex > 0 ? trimmed.slice(0, dotIndex) : trimmed +} + +export async function createLocalGuideImage({ + createNode, + file, + levelId, + position = [0, 0, 0], +}: { + createNode: (node: GuideNodeType, parentId: AnyNodeId) => void + file: File + levelId: string + position?: [number, number, number] +}) { + const assetUrl = await saveAsset(file) + const guide = GuideNode.parse({ + name: getGuideImageName(file.name), + url: assetUrl, + position, + rotation: [0, 0, 0], + scale: 1, + opacity: 50, + scaleReference: null, + }) + + createNode(guide, levelId as AnyNodeId) + return guide +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index fbce4311a..560dc0948 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -11,10 +11,10 @@ import { type LevelNode, type RoofNode, type RoofSegmentNode, - type SpawnNode, type RoofSurfaceMaterialRole, type SlabNode, type Space, + type SpawnNode, type StairNode, type StairSegmentNode, type StairSurfaceMaterialRole, @@ -98,7 +98,11 @@ export type MovingFenceEndpoint = { endpoint: 'start' | 'end' } -export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole +export type MaterialTargetRole = + | WallSurfaceSide + | StairSurfaceMaterialRole + | RoofSurfaceMaterialRole + | SingleSurfaceMaterialRole export type SelectedMaterialTarget = { nodeId: AnyNodeId @@ -111,6 +115,11 @@ type MaterialPaintSelectionSnapshot = { activePaintMaterial: ActivePaintMaterial | null } +export type GuideUiState = { + locked?: boolean + scaleReferenceVisible?: boolean +} + type EditorState = { phase: Phase setPhase: (phase: Phase) => void @@ -177,6 +186,10 @@ type EditorState = { setPaintPanelOpen: (open: boolean) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void + guideUi: Record + setGuideLocked: (guideId: string, locked: boolean) => void + setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void + clearGuideUi: (guideId: string) => void // Space detection for cutaway mode spaces: Record setSpaces: (spaces: Record) => void @@ -201,6 +214,13 @@ type EditorState = { setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void gridSnapStep: GridSnapStep setGridSnapStep: (step: GridSnapStep) => void + showReferenceFloor: boolean + toggleReferenceFloor: () => void + setShowReferenceFloor: (show: boolean) => void + referenceFloorOffset: number + setReferenceFloorOffset: (offset: number) => void + referenceFloorOpacity: number + setReferenceFloorOpacity: (opacity: number) => void // First-person walkthrough mode (street view) isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null @@ -230,6 +250,9 @@ type PersistedEditorLayoutState = Pick< | 'splitOrientation' | 'floorplanSelectionTool' | 'gridSnapStep' + | 'showReferenceFloor' + | 'referenceFloorOffset' + | 'referenceFloorOpacity' > type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState @@ -249,6 +272,9 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = splitOrientation: 'horizontal', floorplanSelectionTool: 'click', gridSnapStep: 0.5, + showReferenceFloor: false, + referenceFloorOffset: 1, + referenceFloorOpacity: 0.35, } const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] @@ -358,6 +384,16 @@ function normalizePersistedEditorLayoutState( gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep) ? (state?.gridSnapStep as GridSnapStep) : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, + showReferenceFloor: state?.showReferenceFloor === true, + referenceFloorOffset: + typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 + ? Math.floor(state.referenceFloorOffset) + : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset, + referenceFloorOpacity: + typeof state?.referenceFloorOpacity === 'number' && + Number.isFinite(state.referenceFloorOpacity) + ? Math.min(0.8, Math.max(0.1, state.referenceFloorOpacity)) + : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity, } } @@ -605,6 +641,36 @@ const useEditor = create()( setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), + guideUi: {}, + setGuideLocked: (guideId, locked) => + set((state) => ({ + guideUi: { + ...state.guideUi, + [guideId]: { + ...state.guideUi[guideId], + locked, + }, + }, + })), + setGuideScaleReferenceVisible: (guideId, visible) => + set((state) => ({ + guideUi: { + ...state.guideUi, + [guideId]: { + ...state.guideUi[guideId], + scaleReferenceVisible: visible, + }, + }, + })), + clearGuideUi: (guideId) => + set((state) => { + if (!state.guideUi[guideId]) { + return state + } + const guideUi = { ...state.guideUi } + delete guideUi[guideId] + return { guideUi } + }), spaces: {}, setSpaces: (spaces) => set({ spaces }), editingHole: null, @@ -636,6 +702,16 @@ const useEditor = create()( setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, setGridSnapStep: (step) => set({ gridSnapStep: step }), + showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, + toggleReferenceFloor: () => + set((state) => ({ showReferenceFloor: !state.showReferenceFloor })), + setShowReferenceFloor: (show) => set({ showReferenceFloor: show }), + referenceFloorOffset: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset, + setReferenceFloorOffset: (offset) => + set({ referenceFloorOffset: Math.max(1, Math.floor(offset)) }), + referenceFloorOpacity: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity, + setReferenceFloorOpacity: (opacity) => + set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }), allowUndergroundCamera: false, setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), isFirstPersonMode: false, @@ -703,6 +779,9 @@ const useEditor = create()( splitOrientation: state.splitOrientation, floorplanSelectionTool: state.floorplanSelectionTool, gridSnapStep: state.gridSnapStep, + showReferenceFloor: state.showReferenceFloor, + referenceFloorOffset: state.referenceFloorOffset, + referenceFloorOpacity: state.referenceFloorOpacity, }), }, ), diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 1c214282c..77c4ed753 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -30,6 +30,8 @@ }, "dependencies": { "polygon-clipping": "^0.15.7", + "three-bvh-csg": "^0.0.18", + "three-mesh-bvh": "^0.9.8", "zustand": "^5" }, "devDependencies": { diff --git a/packages/viewer/src/components/renderers/door/door-renderer.tsx b/packages/viewer/src/components/renderers/door/door-renderer.tsx index 6c978f3e4..ff389f31b 100644 --- a/packages/viewer/src/components/renderers/door/door-renderer.tsx +++ b/packages/viewer/src/components/renderers/door/door-renderer.tsx @@ -1,8 +1,9 @@ import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' -import { useLayoutEffect, useMemo, useRef } from 'react' -import type { Mesh } from 'three' +import { useLayoutEffect, useRef } from 'react' +import { MeshBasicMaterial, type Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, DEFAULT_DOOR_MATERIAL } from '../../../lib/materials' + +const doorHitboxMaterial = new MeshBasicMaterial({ visible: false }) export const DoorRenderer = ({ node }: { node: DoorNode }) => { const ref = useRef(null!) @@ -14,16 +15,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { const handlers = useNodeEvents(node, 'door') const isTransient = !!(node.metadata as Record | null)?.isTransient - const material = useMemo(() => { - const mat = node.material - if (!mat) return DEFAULT_DOOR_MATERIAL - return createMaterial(mat) - }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) - return ( { position={node.position} ref={ref} rotation={[0, node.rotation[1], 0]} - visible={showGuides} + visible={showGuides && node.visible !== false} > {resolvedUrl && ( diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 5bcfbd2da..86b44d23c 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -1,8 +1,6 @@ import { type AnimationEffect, type AnyNodeId, - baseMaterial, - glassMaterial, type Interactive, type ItemNode, type LightEffect, @@ -21,6 +19,7 @@ import { positionLocal, smoothstep, time } from 'three/tsl' import { MeshStandardNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveCdnUrl } from '../../../lib/asset-url' +import { baseMaterial, glassMaterial } from '../../../lib/materials' import { useItemLightPool } from '../../../store/use-item-light-pool' import { requestItemMeshMetadataSync, diff --git a/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx index e7dec6a42..62d477191 100644 --- a/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx +++ b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx @@ -54,13 +54,13 @@ export const SpawnRenderer = ({ node }: { node: SpawnNode }) => { - - + + - - + + diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 1a77e688f..13f478b5c 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,27 +1,25 @@ 'use client' -import { - CeilingSystem, - DoorSystem, - FenceSystem, - ItemSystem, - RoofSystem, - SlabSystem, - StairSystem, - WallSystem, - WindowSystem, -} from '@pascal-app/core' import { Bvh } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' import useViewer from '../../store/use-viewer' +import { CeilingSystem } from '../../systems/ceiling/ceiling-system' +import { DoorSystem } from '../../systems/door/door-system' +import { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' +import { ItemSystem } from '../../systems/item/item-system' import { ItemLightSystem } from '../../systems/item-light/item-light-system' import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system' import { LevelSystem } from '../../systems/level/level-system' +import { RoofSystem } from '../../systems/roof/roof-system' import { ScanSystem } from '../../systems/scan/scan-system' +import { SlabSystem } from '../../systems/slab/slab-system' +import { StairSystem } from '../../systems/stair/stair-system' import { WallCutout } from '../../systems/wall/wall-cutout' +import { WallSystem } from '../../systems/wall/wall-system' +import { WindowSystem } from '../../systems/window/window-system' import { ZoneSystem } from '../../systems/zone/zone-system' import { ErrorBoundary } from '../error-boundary' import { SceneRenderer } from '../renderers/scene-renderer' diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 0aec65ddb..b390e4366 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -7,6 +7,22 @@ import { resolveMaterial, } from '@pascal-app/core' import * as THREE from 'three' +import { MeshStandardNodeMaterial } from 'three/webgpu' + +export const baseMaterial = new MeshStandardNodeMaterial({ + color: '#f2f0ed', + roughness: 0.5, + metalness: 0.0, +}) + +export const glassMaterial = new MeshStandardNodeMaterial({ + color: '#e0f2fe', + roughness: 0.05, + metalness: 0.0, + transparent: true, + opacity: 0.35, + side: THREE.DoubleSide, +}) const sideMap: Record = { front: THREE.FrontSide, diff --git a/packages/core/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx similarity index 94% rename from packages/core/src/systems/ceiling/ceiling-system.tsx rename to packages/viewer/src/systems/ceiling/ceiling-system.tsx index 4f47fab1e..5e383d2b2 100644 --- a/packages/core/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -1,8 +1,6 @@ import { useFrame } from '@react-three/fiber' +import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core' import * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import type { AnyNodeId, CeilingNode } from '../../schema' -import useScene from '../../store/use-scene' function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx similarity index 77% rename from packages/core/src/systems/door/door-system.tsx rename to packages/viewer/src/systems/door/door-system.tsx index b9b5c9e9d..a782ed954 100644 --- a/packages/core/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1,9 +1,12 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + type DoorNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' import * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { baseMaterial, glassMaterial } from '../../materials' -import type { AnyNodeId, DoorNode } from '../../schema' -import useScene from '../../store/use-scene' +import { baseMaterial, glassMaterial } from '../../lib/materials' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) @@ -52,6 +55,12 @@ function addBox( parent.add(m) } +function disposeObject(object: THREE.Object3D) { + object.traverse((child) => { + if (child instanceof THREE.Mesh) child.geometry.dispose() + }) +} + function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -65,13 +74,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // Dispose and remove all old visual children; preserve 'cutout' for (const child of [...mesh.children]) { if (child.name === 'cutout') continue - if (child instanceof THREE.Mesh) child.geometry.dispose() + disposeObject(child) mesh.remove(child) } const { width, height, + openingKind, frameThickness, frameDepth, threshold, @@ -85,8 +95,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { panicBarHeight, contentPadding, hingesSide, + swingDirection, + swingAngle = 0, } = node const hasLeafContent = segments.some((seg) => seg.type !== 'empty') + const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle)) + + if (openingKind === 'opening') { + syncDoorCutout(node, mesh) + return + } // Leaf occupies the full opening (no bottom frame bar — door opens to floor) const leafW = width - 2 * frameThickness @@ -94,6 +112,23 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const leafDepth = 0.04 // Leaf center is shifted down from door center by half the top frame const leafCenterY = -frameThickness / 2 + const hingeX = hingesSide === 'right' ? leafW / 2 : -leafW / 2 + const swingDirectionSign = swingDirection === 'inward' ? 1 : -1 + const hingeDirectionSign = hingesSide === 'right' ? 1 : -1 + const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign + const leafGroup = new THREE.Group() + leafGroup.position.set(hingeX, 0, 0) + leafGroup.rotation.y = leafSwingRotation + mesh.add(leafGroup) + const addLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) // ── Frame members ── // Left post — full height @@ -149,16 +184,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const cpY = contentPadding[1] if (hasLeafContent && cpY > 0) { // Top strip - addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) + addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) // Bottom strip - addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) + addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) } if (hasLeafContent && cpX > 0) { const innerH = leafH - 2 * cpY // Left strip - addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) + addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) // Right strip - addBox(mesh, baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0) + addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0) } // Content area inside padding @@ -193,8 +228,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { cx = -contentW / 2 for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! - addBox( - mesh, + addLeafBox( baseMaterial, seg.dividerThickness, segH, @@ -215,17 +249,17 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { if (seg.type === 'glass') { // Glass only — no opaque backing so it's truly transparent const glassDepth = Math.max(0.004, leafDepth * 0.15) - addBox(mesh, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) } else if (seg.type === 'panel') { // Opaque leaf backing for this column - addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) // Raised panel detail const panelW = colW - 2 * seg.panelInset const panelH = segH - 2 * seg.panelInset if (panelW > 0.01 && panelH > 0.01) { const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) const panelZ = leafDepth / 2 + effectiveDepth / 2 - addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) } } else { // 'empty' leaves the opening unfilled @@ -246,33 +280,24 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045 // Backplate - addBox(mesh, baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) // Grip lever - addBox(mesh, baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) + addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) } // ── Door closer (commercial hardware at top) ── if (hasLeafContent && doorCloser) { const closerY = leafCenterY + leafH / 2 - 0.04 // Body - addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) + addLeafBox(baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) // Arm (simplified as thin bar to frame side) - addBox( - mesh, - baseMaterial, - 0.14, - 0.015, - 0.015, - leafW / 4, - closerY + 0.025, - leafDepth / 2 + 0.015, - ) + addLeafBox(baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015) } // ── Panic bar ── if (hasLeafContent && panicBar) { const barY = panicBarHeight - height / 2 - addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) + addLeafBox(baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) } // ── Hinges (3 knuckle-style hinges on the hinge side) ── @@ -290,6 +315,10 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ) } + syncDoorCutout(node, mesh) +} + +function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { // ── Cutout (for wall CSG) — always full door dimensions, 1m deep ── let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined if (!cutout) { diff --git a/packages/core/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx similarity index 96% rename from packages/core/src/systems/fence/fence-system.tsx rename to packages/viewer/src/systems/fence/fence-system.tsx index 4fcc1a866..c0a2605cb 100644 --- a/packages/core/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -1,10 +1,14 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + type FenceNode, + getWallCurveFrameAt, + getWallCurveLength, + sceneRegistry, + useScene, +} from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import type { AnyNodeId, FenceNode } from '../../schema' -import useScene from '../../store/use-scene' -import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve' type FencePart = { position: [number, number, number] diff --git a/packages/core/src/systems/item/item-system.tsx b/packages/viewer/src/systems/item/item-system.tsx similarity index 84% rename from packages/core/src/systems/item/item-system.tsx rename to packages/viewer/src/systems/item/item-system.tsx index dbfbf7e33..75173160e 100644 --- a/packages/core/src/systems/item/item-system.tsx +++ b/packages/viewer/src/systems/item/item-system.tsx @@ -1,10 +1,15 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + getScaledDimensions, + type ItemNode, + resolveLevelId, + sceneRegistry, + spatialGridManager, + useScene, + type WallNode, +} from '@pascal-app/core' import type * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' -import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import { type AnyNodeId, getScaledDimensions, type ItemNode, type WallNode } from '../../schema' -import useScene from '../../store/use-scene' // ============================================================================ // ITEM SYSTEM diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx similarity index 97% rename from packages/core/src/systems/roof/roof-system.tsx rename to packages/viewer/src/systems/roof/roof-system.tsx index 5a1f06d11..a7479fb0b 100644 --- a/packages/core/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1,21 +1,30 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNode, + type AnyNodeId, + type RoofNode, + type RoofSegmentNode, + type RoofType, + sceneRegistry, + useScene, +} from '@pascal-app/core' import * as THREE from 'three' import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '../../schema' -import type { RoofType } from '../../schema/nodes/roof-segment' -import useScene from '../../store/use-scene' const csgEvaluator = new Evaluator() csgEvaluator.useGroups = true ;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash csgEvaluator.attributes = ['position', 'normal', 'uv'] +function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { + ;(geometry as any).computeBoundsTree = computeBoundsTree + ;(geometry as any).computeBoundsTree({ maxLeafSize: 10 }) +} + function prepareBrushForCSG(brush: Brush) { - brush.geometry.computeBoundsTree = computeBoundsTree - brush.geometry.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(brush.geometry) brush.updateMatrixWorld() } @@ -78,8 +87,7 @@ export const RoofSystem = () => { mesh.geometry.dispose() const placeholder = new THREE.BufferGeometry() placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - placeholder.computeBoundsTree = computeBoundsTree - placeholder.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(placeholder) mesh.geometry = placeholder } mesh.position.set(node.position[0], node.position[1], node.position[2]) @@ -134,8 +142,7 @@ function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) { mesh.geometry.dispose() mesh.geometry = newGeo - newGeo.computeBoundsTree = computeBoundsTree - newGeo.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(newGeo) mesh.position.set(node.position[0], node.position[1], node.position[2]) mesh.rotation.y = node.rotation @@ -524,8 +531,7 @@ export function getRoofSegmentBrushes( // when a group exists but covers no triangles (can happen after mergeVertices) geo.groups = geo.groups.filter((g) => g.count > 0) if (geo.groups.length === 0) return null - geo.computeBoundsTree = computeBoundsTree - geo.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(geo) const brush = new Brush(geo, dummyMats) brush.updateMatrixWorld() return brush diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx similarity index 70% rename from packages/core/src/systems/slab/slab-system.tsx rename to packages/viewer/src/systems/slab/slab-system.tsx index 10e756270..d9f02df9c 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -1,9 +1,12 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + getRenderableSlabPolygon, + sceneRegistry, + type SlabNode, + useScene, +} from '@pascal-app/core' import * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { insetPolygonFromCentroid, simplifyClosedPolygon } from '../../lib/polygon-geometry' -import type { AnyNodeId, SlabNode } from '../../schema' -import useScene from '../../store/use-scene' function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') @@ -58,71 +61,6 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { mesh.position.y = elevation < 0 ? elevation : 0 } -/** Half of default wall thickness — used to extend slab geometry under walls */ -const SLAB_OUTSET = 0.05 -const AUTO_SLAB_INSET = 0.02 -const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08 - -function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> { - return slabNode.autoFromWalls - ? simplifyClosedPolygon( - insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET), - AUTO_SLAB_SIMPLIFY_TOLERANCE, - ) - : outsetPolygon(slabNode.polygon, SLAB_OUTSET) -} - -/** - * Expand a polygon outward by a uniform distance. - * Offsets each edge outward then intersects consecutive offset edges. - */ -function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<[number, number]> { - const n = polygon.length - if (n < 3) return polygon - - // Determine winding via signed area - let area2 = 0 - for (let i = 0; i < n; i++) { - const j = (i + 1) % n - area2 += polygon[i]![0] * polygon[j]![1] - polygon[j]![0] * polygon[i]![1] - } - const s = area2 >= 0 ? 1 : -1 - - // Offset each edge outward by amount - const offEdges: Array<[number, number, number, number]> = [] - for (let i = 0; i < n; i++) { - const j = (i + 1) % n - const dx = polygon[j]![0] - polygon[i]![0] - const dz = polygon[j]![1] - polygon[i]![1] - const len = Math.sqrt(dx * dx + dz * dz) - if (len < 1e-9) { - offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz]) - continue - } - const nx = ((s * dz) / len) * amount - const nz = ((s * -dx) / len) * amount - offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz]) - } - - // Intersect consecutive offset edges to get new vertices - const result: Array<[number, number]> = [] - for (let i = 0; i < n; i++) { - const j = (i + 1) % n - const [ax, az, adx, adz] = offEdges[i]! - const [bx, bz, bdx, bdz] = offEdges[j]! - const denom = adx * bdz - adz * bdx - if (Math.abs(denom) < 1e-9) { - // Parallel edges — use offset endpoint - result.push([ax + adx, az + adz]) - } else { - const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom - result.push([ax + t * adx, az + t * adz]) - } - } - - return result -} - /** * Generates extruded slab geometry from polygon */ @@ -199,13 +137,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight) } - const pushWallVertex = ( - x: number, - y: number, - z: number, - u: number, - v: number, - ) => { + const pushWallVertex = (x: number, y: number, z: number, u: number, v: number) => { positions.push(x, y, z) uvs.push(u, v) } diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx similarity index 98% rename from packages/core/src/systems/stair/stair-system.tsx rename to packages/viewer/src/systems/stair/stair-system.tsx index 77e400296..6f1e96d58 100644 --- a/packages/core/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -1,13 +1,18 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNode, + type AnyNodeId, + resolveLevelId, + sceneRegistry, + spatialGridManager, + type StairNode, + type StairSegmentNode, + syncAutoStairOpenings, + useScene, +} from '@pascal-app/core' import { useEffect, useRef } from 'react' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' -import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' -import useScene from '../../store/use-scene' -import { syncAutoStairOpenings } from './stair-opening-sync' const pendingStairUpdates = new Set() const MAX_STAIRS_PER_FRAME = 2 diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 5edccfeab..fb1b4e99c 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -1,5 +1,4 @@ import { - baseMaterial, getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, getWallSurfaceMaterialSignature, @@ -10,7 +9,7 @@ import { import { Color, type Material } from 'three' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' import { MeshStandardNodeMaterial } from 'three/webgpu' -import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' +import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../lib/materials' const DEFAULT_WALL_COLOR = '#f2f0ed' diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx similarity index 75% rename from packages/core/src/systems/wall/wall-system.tsx rename to packages/viewer/src/systems/wall/wall-system.tsx index 5a40c09a8..98c98467e 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -2,21 +2,29 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' -import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import type { AnyNode, AnyNodeId, WallNode } from '../../schema' -import useScene from '../../store/use-scene' -import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' -import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { calculateLevelMiters, + type AnyNode, + type AnyNodeId, + type DoorNode, getAdjacentWallIds, + DEFAULT_WALL_HEIGHT, + getWallCurveFrameAt, getWallMiterBoundaryPoints, + getWallPlanFootprint, + getWallSurfacePolygon, + getWallThickness, + isCurvedWall, type Point2D, pointToKey, + resolveLevelId, + sceneRegistry, + spatialGridManager, + useScene, + type WallNode, type WallMiterData, -} from './wall-mitering' + type WindowNode, +} from '@pascal-app/core' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() @@ -24,6 +32,11 @@ const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 const WALL_FACE_NORMAL_Y_EPSILON = 0.6 const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003 +function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { + ;(geometry as any).computeBoundsTree = computeBoundsTree + ;(geometry as any).computeBoundsTree({ maxLeafSize: 10 }) +} + type WallBoundaryEdgeTag = 'front' | 'back' | 'base' type TaggedWallBoundaryEdge = { @@ -495,8 +508,7 @@ export function generateExtrudedWall( // Create wall brush from geometry // Pre-compute BVH with new API to avoid deprecation warning - geometry.computeBoundsTree = computeBoundsTree - geometry.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(geometry) const wallBrush = new Brush(geometry) wallBrush.updateMatrixWorld() @@ -546,6 +558,14 @@ function collectCutoutBrushes( for (const child of childrenNodes) { if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue + if ( + (child.type === 'door' && child.openingKind === 'opening') || + (child.type === 'window' && child.openingKind === 'opening') + ) { + brushes.push(createShapedOpeningCutoutBrush(child, wallThickness)) + continue + } + const childMesh = sceneRegistry.nodes.get(child.id) if (!childMesh) continue @@ -591,8 +611,7 @@ function collectCutoutBrushes( ) // Pre-compute BVH with new API to avoid deprecation warning - boxGeo.computeBoundsTree = computeBoundsTree - boxGeo.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(boxGeo) const brush = new Brush(boxGeo) brushes.push(brush) @@ -600,3 +619,184 @@ function collectCutoutBrushes( return brushes } + +type ShapedOpeningNode = DoorNode | WindowNode +type CornerRadii = { + topLeft: number + topRight: number + bottomRight: number + bottomLeft: number +} + +function createShapedOpeningCutoutBrush(opening: ShapedOpeningNode, wallThickness: number): Brush { + const shape = createShapedOpeningCutoutShape(opening) + const depth = wallThickness * 2 + const bevelSize = + opening.openingShape === 'rounded' + ? Math.min( + Math.max(opening.openingRevealRadius ?? 0.025, 0), + Math.max(wallThickness * 0.45, 0.001), + Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001), + ) + : 0 + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: bevelSize > 0, + bevelSegments: bevelSize > 0 ? 8 : 0, + bevelSize, + bevelThickness: bevelSize, + curveSegments: 24, + }) + + geometry.translate(0, 0, -depth / 2) + computeGeometryBoundsTree(geometry) + + return new Brush(geometry) +} + +function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape { + const halfWidth = opening.width / 2 + const bottom = opening.position[1] - opening.height / 2 + const top = opening.position[1] + opening.height / 2 + const centerX = opening.position[0] + const left = centerX - halfWidth + const right = centerX + halfWidth + const width = Math.max(opening.width, 1e-6) + const height = Math.max(opening.height, 1e-6) + const shape = new THREE.Shape() + + if (opening.openingShape === 'arch') { + const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height) + const springY = top - archHeight + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, springY) + shape.quadraticCurveTo(centerX, top, left, springY) + shape.lineTo(left, bottom) + shape.closePath() + return shape + } + + if (opening.openingShape === 'rounded') { + const radii = getRoundedOpeningRadii(opening, width, height) + applyRoundedOpeningShape(shape, left, right, bottom, top, radii) + return shape + } + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, top) + shape.lineTo(left, top) + shape.closePath() + return shape +} + +function getRoundedOpeningRadii( + opening: ShapedOpeningNode, + width: number, + height: number, +): CornerRadii { + if (opening.type !== 'window') { + if (opening.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15] + + return normalizeCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + bottomRight: 0, + bottomLeft: 0, + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height) + const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 } + } + + if (opening.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = + opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] + + return normalizeCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + bottomRight: Math.max(bottomRight, 0), + bottomLeft: Math.max(bottomLeft, 0), + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height / 2) + const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius } +} + +function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii { + const next = { ...radii } + const maxScale = Math.min( + 1, + width / Math.max(next.topLeft + next.topRight, 1e-6), + width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), + height / Math.max(next.topLeft + next.bottomLeft, 1e-6), + height / Math.max(next.topRight + next.bottomRight, 1e-6), + ) + + if (maxScale < 1) { + next.topLeft *= maxScale + next.topRight *= maxScale + next.bottomRight *= maxScale + next.bottomLeft *= maxScale + } + + return next +} + +function applyRoundedOpeningShape( + shape: THREE.Shape, + left: number, + right: number, + bottom: number, + top: number, + radii: CornerRadii, +) { + const { topLeft, topRight, bottomRight, bottomLeft } = radii + + shape.moveTo(left + bottomLeft, bottom) + shape.lineTo(right - bottomRight, bottom) + if (bottomRight > 1e-6) { + shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false) + } else { + shape.lineTo(right, bottom) + } + + shape.lineTo(right, top - topRight) + if (topRight > 1e-6) { + shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false) + } else { + shape.lineTo(right, top) + } + + shape.lineTo(left + topLeft, top) + if (topLeft > 1e-6) { + shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false) + } else { + shape.lineTo(left, top) + } + + shape.lineTo(left, bottom + bottomLeft) + if (bottomLeft > 1e-6) { + shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false) + } else { + shape.lineTo(left, bottom) + } + + shape.closePath() +} diff --git a/packages/core/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx similarity index 94% rename from packages/core/src/systems/window/window-system.tsx rename to packages/viewer/src/systems/window/window-system.tsx index d27e28578..01a77f430 100644 --- a/packages/core/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,9 +1,12 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + sceneRegistry, + useScene, + type WindowNode, +} from '@pascal-app/core' import * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { baseMaterial, glassMaterial } from '../../materials' -import type { AnyNodeId, WindowNode } from '../../schema' -import useScene from '../../store/use-scene' +import { baseMaterial, glassMaterial } from '../../lib/materials' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) @@ -81,8 +84,14 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { sill, sillDepth, sillThickness, + openingKind, } = node + if (openingKind === 'opening') { + syncWindowCutout(node, mesh) + return + } + const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness @@ -231,6 +240,10 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { ) } + syncWindowCutout(node, mesh) +} + +function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ── let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined if (!cutout) {