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 00000000..4529c01c --- /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 00000000..6b396577 --- /dev/null +++ b/apps/editor/public/items/sofa/floor-plan.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42ae2514..638aa083 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,23 +46,23 @@ 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' @@ -70,7 +70,7 @@ 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 { getRenderableSlabPolygon, SlabSystem } from './systems/slab/slab-system' export { StairSystem } from './systems/stair/stair-system' export { getClampedWallCurveOffset, diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/core/src/systems/slab/slab-system.tsx index 10e75627..b4082efd 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/core/src/systems/slab/slab-system.tsx @@ -63,7 +63,7 @@ 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]> { +export function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> { return slabNode.autoFromWalls ? simplifyClosedPolygon( insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET), @@ -199,13 +199,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/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index 1fdc5c81..d08f3e84 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 447cd734..9885d6fc 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/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 4843da83..8d634076 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -11,6 +11,7 @@ import { type FenceNode, type GridEvent, type GuideNode, + getRenderableSlabPolygon, getWallChordFrame, getWallCurveFrameAt, getWallCurveLength, @@ -118,6 +119,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 +130,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 +169,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 @@ -351,12 +373,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 +484,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[] @@ -523,6 +633,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 @@ -1441,6 +1565,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 +1643,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], @@ -1834,7 +2034,8 @@ function formatArea(areaSqM: number, unit: 'metric' | 'imperial') { const areaSqFt = areaSqM * 10.763_910_4 return ( <> - {Math.round(areaSqFt).toLocaleString()} ft + {Math.round(areaSqFt).toLocaleString()} + ft 2 @@ -1843,7 +2044,8 @@ function formatArea(areaSqM: number, unit: 'metric' | 'imperial') { } return ( <> - {Number.parseFloat(areaSqM.toFixed(1))} m + {Number.parseFloat(areaSqM.toFixed(1))} + m 2 @@ -2908,7 +3110,7 @@ const FloorplanGridLayer = memo(function FloorplanGridLayer({ opacity={palette.minorGridOpacity} shapeRendering="crispEdges" stroke={palette.minorGrid} - strokeWidth="0.02" + strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH} vectorEffect="non-scaling-stroke" /> @@ -2918,7 +3120,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" /> @@ -3172,6 +3374,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ openingsPolygons, palette, selectedIdSet, + slabSelectionHatchId, slabPolygons, wallPolygons, wallSelectionHatchId, @@ -3204,6 +3407,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ openingsPolygons: OpeningPolygonEntry[] palette: FloorplanPalette selectedIdSet: ReadonlySet + slabSelectionHatchId: string slabPolygons: SlabPolygonEntry[] wallPolygons: WallPolygonEntry[] wallSelectionHatchId: string @@ -3217,7 +3421,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ 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 +3431,18 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ : showSelectedSlabStyle ? palette.selectedSlabStroke : palette.slabStroke - const slabBorderWidth = showSelectedSlabStyle ? '0.065' : '0.05' + const slabBorderWidth = showSelectedSlabStyle ? '1.2' : '1' let slabLabel = null if (isSelected) { - const { area, centroid } = getSlabArea(polygon, holes) + const { area, centroid } = getSlabArea(visualPolygon, visualHoles) if (area > 0) { slabLabel = ( + + {isSelected && !isDeleteHovered ? ( + + ) : null} @@ -3319,20 +3535,14 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ : showSelectedCeilingStyle ? palette.selectedCeilingStroke : palette.ceilingStroke - const ceilingBorderWidth = showSelectedCeilingStyle ? '0.065' : '0.05' + const ceilingBorderWidth = showSelectedCeilingStyle ? '1.2' : '1' return ( + {isSelected && !isDeleteHovered ? ( + + ) : null} @@ -3436,7 +3655,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" /> @@ -3652,7 +3875,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ if (opening.type === 'door') { if (polygon.length < 4) return null - if (!centerLine) return null const [p1, p2, p3, p4] = polygon const svgP1 = toSvgPoint(p1!) const svgP2 = toSvgPoint(p2!) @@ -3660,30 +3882,22 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ const svgP4 = toSvgPoint(p4!) const centerX = (p1!.x + p2!.x + p3!.x + p4!.x) / 4 const centerY = (p1!.y + p2!.y + p3!.y + p4!.y) / 4 - const cx = toSvgX(centerX) - const cy = toSvgY(centerY) const dirX = svgP2.x - svgP1.x const dirY = svgP2.y - svgP1.y const len = Math.sqrt(dirX * dirX + dirY * dirY) + if (len < 1e-6) return null + + const cx = toSvgX(centerX) + const cy = toSvgY(centerY) const nx = dirX / len const ny = dirY / len - const px = -ny const py = nx const hingesSide = opening.hingesSide ?? 'left' const swingDirection = opening.swingDirection ?? 'inward' const width = opening.width - const doorColor = isSelected || isSelectionHighlighted ? '#f97316' : '#3f3f46' - const swingColor = - isSelected || isSelectionHighlighted - ? 'rgba(249, 115, 22, 0.78)' - : 'rgba(63, 63, 70, 0.35)' - const openingFill = - isSelected || isSelectionHighlighted - ? 'rgba(249, 115, 22, 0.12)' - : 'rgba(255, 255, 255, 0.96)' const sweepFlag = hingesSide === 'left' ? swingDirection === 'inward' @@ -3695,12 +3909,80 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ const hx = cx - nx * (width / 2) * (hingesSide === 'left' ? 1 : -1) const hy = cy - ny * (width / 2) * (hingesSide === 'left' ? 1 : -1) - - const ox = hx + px * width * (swingDirection === 'inward' ? 1 : -1) - const oy = hy + py * width * (swingDirection === 'inward' ? 1 : -1) - + const swingSign = swingDirection === 'inward' ? 1 : -1 const ox2 = cx + nx * (width / 2) * (hingesSide === 'left' ? 1 : -1) const oy2 = cy + ny * (width / 2) * (hingesSide === 'left' ? 1 : -1) + const arcStrokeWidth = isSelected || isSelectionHighlighted ? '2' : '1.25' + const depthDirectionSign = + Math.sign((svgP4.x - svgP1.x) * px + (svgP4.y - svgP1.y) * py) || 1 + const depthExtraOffset = 0.005 + const doorCubeSize = Math.min(Math.max(width * 0.08, 0.06), 0.12) + const doorCubeInset = doorCubeSize * 0.5 + const doorCubeStroke = palette.openingStroke + const hingeCubeCenter = { x: hx + nx * doorCubeInset, y: hy + ny * doorCubeInset } + const strikeCubeCenter = { x: ox2 - nx * doorCubeInset, y: oy2 - ny * doorCubeInset } + const hingeTangentSign = hingesSide === 'left' ? 1 : -1 + const leafHalfThickness = doorCubeSize * 0.18 + const leafSideOffset = hingeTangentSign * (doorCubeSize / 2 + leafHalfThickness) + const leafStart = { + x: hingeCubeCenter.x + px * swingSign * (doorCubeSize / 2) + nx * leafSideOffset, + y: hingeCubeCenter.y + py * swingSign * (doorCubeSize / 2) + ny * leafSideOffset, + } + const arcEnd = { + x: + strikeCubeCenter.x + + px * swingSign * (doorCubeSize / 2) - + nx * hingeTangentSign * (doorCubeSize / 2), + y: + strikeCubeCenter.y + + py * swingSign * (doorCubeSize / 2) - + ny * hingeTangentSign * (doorCubeSize / 2), + } + const swingRadius = Math.hypot(arcEnd.x - leafStart.x, arcEnd.y - leafStart.y) + const leafEnd = { + x: leafStart.x + px * swingSign * swingRadius, + y: leafStart.y + py * swingSign * swingRadius, + } + const doorBackgroundPoints = [ + { + x: svgP1.x - px * depthDirectionSign * depthExtraOffset, + y: svgP1.y - py * depthDirectionSign * depthExtraOffset, + }, + { + x: svgP2.x - px * depthDirectionSign * depthExtraOffset, + y: svgP2.y - py * depthDirectionSign * depthExtraOffset, + }, + { + x: svgP3.x + px * depthDirectionSign * depthExtraOffset, + y: svgP3.y + py * depthDirectionSign * depthExtraOffset, + }, + { + x: svgP4.x + px * depthDirectionSign * depthExtraOffset, + y: svgP4.y + py * depthDirectionSign * depthExtraOffset, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + 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(' ') return ( )} + + {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( + + ))} - {[centerLine.start, centerLine.end].map((point, index) => { - const svgPoint = toSvgPoint(point) - const jambSize = 0.18 - return ( - - ) - })} - - - {isSelected ? ( - <> - - - - - ) : null} ) } @@ -4107,6 +4341,16 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ // Renders an item's 2D floor-plan image (top-down view, object-fit:contain) // inside its footprint rectangle. Placed at the same scene position/rotation // as the polygon so it lines up exactly. +const FLOORPLAN_ITEM_ICON_OVERRIDES: Record = { + '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 +4409,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ selectedIdSet, stairEntries, unit, + wallSelectionHatchId, }: { canFocusItems: boolean canFocusStairs: boolean @@ -4190,6 +4435,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 +4449,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 +4468,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 +4515,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 +4529,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 +4553,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 +5081,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 +5148,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 +5206,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 +5273,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 +5322,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 +5432,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ event: ReactPointerEvent, ) => void palette: FloorplanPalette + unitsPerPixel: number }) { return ( <> @@ -5149,7 +5440,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 +5490,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 +5551,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 +5564,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 +5682,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,6 +5718,24 @@ 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) @@ -5393,6 +5755,7 @@ 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) @@ -5757,33 +6120,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 +6215,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) => { @@ -6153,8 +6601,7 @@ export function FloorplanPanel() { bestHit.point, formatMeasurement(bestHit.distance, unit), { - offsetDistance: FLOORPLAN_MEASUREMENT_OFFSET, - offsetVector: tangent, + extensionOvershoot: 0, }, ) @@ -6331,6 +6778,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 +6794,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 @@ -6529,14 +6980,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 +7067,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 +7080,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 +7166,117 @@ export function FloorplanPanel() { return [] } - return selectedSlabEntry.polygon.map((point, vertexIndex) => ({ - nodeId: selectedSlabEntry.slab.id, - vertexIndex, - point: toWallPlanPoint(point), - isActive: - slabVertexDragState?.slabId === selectedSlabEntry.slab.id && - slabVertexDragState.vertexIndex === 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 [] } - return selectedSlabEntry.polygon.map((point, edgeIndex, polygon) => { + 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: + slabHoleVertexDragState?.slabId === selectedSlabEntry.slab.id && + slabHoleVertexDragState.holeIndex === selectedSlabEditingHoleIndex && + slabHoleVertexDragState.vertexIndex === vertexIndex, + })) + }, [ + selectedSlabEditingHole, + selectedSlabEditingHoleIndex, + selectedSlabEntry, + shouldShowSlabHoleBoundaryHandles, + slabHoleVertexDragState, + ]) + const slabHoleMidpointHandles = useMemo(() => { + if ( + !( + shouldShowSlabHoleBoundaryHandles && + selectedSlabEntry && + selectedSlabEditingHole && + !slabHoleVertexDragState + ) + ) { + return [] + } + + return selectedSlabEditingHole.map((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + return { nodeId: selectedSlabEntry.slab.id, edgeIndex, @@ -6717,7 +7286,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 +7516,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 +7563,7 @@ export function FloorplanPanel() { width, } }, [ - ceilingPolygons, + displayCeilingPolygons, displaySlabPolygons, floorplanFenceEntries, floorplanItemEntries, @@ -7010,6 +7642,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 +7666,12 @@ export function FloorplanPanel() { levelId, movingFenceEndpoint, movingNode, + ceilingVertexDragState, + ceilingHoleMoveDraft, + ceilingHoleVertexDragState, siteVertexDragState, + slabHoleMoveDraft, + slabHoleVertexDragState, slabVertexDragState, zoneVertexDragState, ]) @@ -7065,7 +7707,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,20 +7728,36 @@ 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 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 @@ -7253,37 +7915,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 +7975,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 +8002,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 +8028,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 +8059,7 @@ export function FloorplanPanel() { ), [gridSteps.majorStep, viewBox], ) + const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) const getSvgPointFromClientPoint = useCallback( (clientX: number, clientY: number): SvgPoint | null => { @@ -7412,6 +8104,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 +8353,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 +8390,11 @@ export function FloorplanPanel() { clearWallCurveDrag() clearSiteBoundaryInteraction() clearSlabBoundaryInteraction() + clearCeilingBoundaryInteraction() clearZoneBoundaryInteraction() setCursorPoint(null) }, [ + clearCeilingBoundaryInteraction, clearFencePlacementDraft, clearCeilingPlacementDraft, clearRoofPlacementDraft, @@ -8330,6 +9055,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 +9209,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) { @@ -8539,6 +9292,471 @@ export function FloorplanPanel() { updateNode, ]) + useEffect(() => { + const dragState = ceilingVertexDragState + 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) + + setCeilingBoundaryDraft((currentDraft) => { + if (!currentDraft || currentDraft.ceilingId !== dragState.ceilingId) { + 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 commitCeilingVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + 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() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + updateNode(draft.ceilingId, { + polygon: draft.polygon, + }) + sfxEmitter.emit('sfx:structure-build') + } + + clearCeilingBoundaryInteraction() + setCursorPoint(null) + } + + const cancelCeilingVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + clearCeilingBoundaryInteraction() + setCursorPoint(null) + } + + window.addEventListener('pointermove', handleWindowPointerMove) + window.addEventListener('pointerup', commitCeilingVertexDrag) + window.addEventListener('pointercancel', cancelCeilingVertexDrag) + + return () => { + window.removeEventListener('pointermove', handleWindowPointerMove) + window.removeEventListener('pointerup', commitCeilingVertexDrag) + window.removeEventListener('pointercancel', cancelCeilingVertexDrag) + } + }, [ + ceilingById, + ceilingVertexDragState, + clearCeilingBoundaryInteraction, + getPlanPointFromClientPoint, + updateNode, + ]) + + useEffect(() => { + const dragState = slabHoleVertexDragState + 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) + + 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, + } + }) + } + + const commitSlabHoleVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + 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) + } + + const cancelSlabHoleVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + clearSlabHoleBoundaryInteraction() + setCursorPoint(null) + } + + 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, + ]) + + useEffect(() => { + const moveDraft = slabHoleMoveDraft + 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) + 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) { @@ -8753,6 +9971,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 } @@ -8944,7 +10182,12 @@ export function FloorplanPanel() { isRoofBuildActive, isWallBuildActive, roofDraftStart, + ceilingHoleMoveDraft, + ceilingHoleVertexDragState, + ceilingVertexDragState, siteVertexDragState, + slabHoleMoveDraft, + slabHoleVertexDragState, slabVertexDragState, shiftPressed, surfaceSize.height, @@ -9111,7 +10354,7 @@ export function FloorplanPanel() { [clearDraft, draftStart], ) const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ - ceilingPolygons, + ceilingPolygons: displayCeilingPolygons, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, @@ -9998,6 +11241,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() @@ -10028,6 +11376,111 @@ export function FloorplanPanel() { }, [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') + }, + [ + getPlanPointFromClientPoint, + selectedCeilingEditingHole, + selectedCeilingEditingHoleIndex, + selectedCeilingEntry, + ], + ) + const handleSelectedCeilingHoleDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const ceiling = selectedCeilingEntry?.ceiling + const holeIndex = selectedCeilingEditingHoleIndex + if (!(ceiling && holeIndex !== null)) { + return + } + + 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') + }, + [selectedCeilingEditingHoleIndex, selectedCeilingEntry, setEditingHole, updateNode], + ) const handleSelectedCeilingDelete = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -10421,20 +11874,32 @@ export function FloorplanPanel() { const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) const vertexPoint = slabEntry?.polygon[vertexIndex] - if (!(slabEntry && vertexPoint)) { + 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(vertexPoint)) + setCursorPoint(toWallPlanPoint(handlePoint)) }, [displaySlabPolygons], ) @@ -10462,7 +11927,11 @@ export function FloorplanPanel() { [clearSlabBoundaryInteraction, slabById, updateNode], ) const handleSlabMidpointPointerDown = useCallback( - (slabId: SlabNode['id'], edgeIndex: number, event: ReactPointerEvent) => { + ( + slabId: SlabNode['id'], + handleEdgeIndex: number, + event: ReactPointerEvent, + ) => { if (event.button !== 0) { return } @@ -10477,6 +11946,141 @@ export function FloorplanPanel() { } 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 + } + + ceilingBoundaryDraftRef.current = null + clearCeilingBoundaryInteraction() + + updateNode(ceilingId, { + polygon: ceiling.polygon.filter((_, index) => index !== vertexIndex), + }) + }, + [ceilingById, clearCeilingBoundaryInteraction, updateNode], + ) + const handleCeilingMidpointPointerDown = useCallback( + ( + ceilingId: CeilingNode['id'], + edgeIndex: number, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredCeilingHandleId(null) + + const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) + if (!ceilingEntry) { + return + } + + const basePolygon = ceilingEntry.polygon.map(toWallPlanPoint) const startPoint = basePolygon[edgeIndex] const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] if (!(startPoint && endPoint)) { @@ -10494,18 +12098,250 @@ export function FloorplanPanel() { ...basePolygon.slice(insertIndex), ] - setSlabBoundaryDraft({ - slabId, + setCeilingBoundaryDraft({ + ceilingId, polygon: nextPolygon, }) - setSlabVertexDragState({ + setCeilingVertexDragState({ pointerId: event.pointerId, - slabId, + ceilingId, vertexIndex: insertIndex, }) setCursorPoint(insertedPoint) }, - [displaySlabPolygons], + [displayCeilingPolygons], + ) + const handleSlabHoleVertexPointerDown = 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 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)) + }, + [displaySlabPolygons, editingHole], + ) + const handleSlabHoleVertexDoubleClick = useCallback( + (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + + 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 + } + + slabHoleBoundaryDraftRef.current = null + clearSlabHoleBoundaryInteraction() + + const nextHoles = [...(slab.holes ?? [])] + nextHoles[holeIndex] = hole.filter((_, index) => index !== vertexIndex) + updateNode(slabId, { + holes: nextHoles, + }) + }, + [clearSlabHoleBoundaryInteraction, editingHole, slabById, updateNode], + ) + const handleSlabHoleMidpointPointerDown = useCallback( + (slabId: SlabNode['id'], edgeIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + 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 + if (!(slabEntry && holeIndex !== null && hole)) { + return + } + + 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, + holeIndex, + polygon: nextPolygon, + }) + 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)) + }, + [displayCeilingPolygons, editingHole], + ) + const handleCeilingHoleVertexDoubleClick = useCallback( + ( + ceilingId: CeilingNode['id'], + vertexIndex: number, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + + 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 + } + + ceilingHoleBoundaryDraftRef.current = null + clearCeilingHoleBoundaryInteraction() + + const nextHoles = [...(ceiling.holes ?? [])] + nextHoles[holeIndex] = hole.filter((_, index) => index !== vertexIndex) + updateNode(ceilingId, { + holes: nextHoles, + }) + }, + [ceilingById, clearCeilingHoleBoundaryInteraction, editingHole, updateNode], + ) + const handleCeilingHoleMidpointPointerDown = useCallback( + ( + ceilingId: CeilingNode['id'], + edgeIndex: 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 + if (!(ceilingEntry && holeIndex !== null && hole)) { + return + } + + 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), + ] + + setCeilingHoleBoundaryDraft({ + ceilingId, + holeIndex, + polygon: nextPolygon, + }) + setCeilingHoleVertexDragState({ + pointerId: event.pointerId, + ceilingId, + holeIndex, + vertexIndex: insertIndex, + }) + setCursorPoint(insertedPoint) + }, + [displayCeilingPolygons, editingHole], ) const handleSiteVertexPointerDown = useCallback( (siteId: SiteNode['id'], vertexIndex: number, event: ReactPointerEvent) => { @@ -10721,7 +12557,12 @@ export function FloorplanPanel() { !( panStateRef.current || wallEndpointDragRef.current || + ceilingVertexDragState || + ceilingHoleMoveDraft || + ceilingHoleVertexDragState || siteVertexDragState || + slabHoleMoveDraft || + slabHoleVertexDragState || slabVertexDragState || zoneVertexDragState ) @@ -10738,6 +12579,7 @@ export function FloorplanPanel() { setHoveredEndpointId(null) setHoveredSiteHandleId(null) setHoveredSlabHandleId(null) + setHoveredCeilingHandleId(null) setHoveredZoneHandleId(null) if (hoveredWallIdRef.current) { emitFloorplanWallLeave(hoveredWallIdRef.current) @@ -10752,7 +12594,12 @@ export function FloorplanPanel() { handleStairHoverChange, handleWallHoverChange, handleZoneHoverChange, + ceilingVertexDragState, + ceilingHoleMoveDraft, + ceilingHoleVertexDragState, siteVertexDragState, + slabHoleMoveDraft, + slabHoleVertexDragState, slabVertexDragState, zoneVertexDragState, ]) @@ -10774,7 +12621,12 @@ export function FloorplanPanel() { !panStateRef.current && !guideInteractionRef.current && !wallEndpointDragRef.current && + !ceilingVertexDragState && + !ceilingHoleMoveDraft && + !ceilingHoleVertexDragState && !siteVertexDragState && + !slabHoleMoveDraft && + !slabHoleVertexDragState && !slabVertexDragState && !zoneVertexDragState ) { @@ -10801,7 +12653,12 @@ export function FloorplanPanel() { [ handlePointerMove, hasFloorplanCursorIndicator, + ceilingVertexDragState, + ceilingHoleMoveDraft, + ceilingHoleVertexDragState, siteVertexDragState, + slabHoleMoveDraft, + slabHoleVertexDragState, slabVertexDragState, zoneVertexDragState, ], @@ -11247,8 +13104,13 @@ export function FloorplanPanel() { + + + @@ -11490,6 +13374,7 @@ export function FloorplanPanel() { handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event) } palette={palette} + unitsPerPixel={floorplanUnitsPerPixel} vertexHandles={siteVertexHandles} /> @@ -11537,6 +13422,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 +13475,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} + /> + @@ -11615,14 +13584,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 +13602,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/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx index f44ede2d..f26ad527 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/viewer-toolbar.tsx b/packages/editor/src/components/ui/viewer-toolbar.tsx index 53fe873e..60530906 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/lib/floorplan/stairs.ts b/packages/editor/src/lib/floorplan/stairs.ts index 3f06d171..68188a6d 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],