From 3c185144e0a1f077af0a76760fcf78544cbcff3e Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 29 Apr 2026 13:45:09 +0530 Subject: [PATCH 01/13] Align outdoor tree footprints in 2D editor --- packages/editor/src/lib/floorplan/items.ts | 41 ++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/lib/floorplan/items.ts b/packages/editor/src/lib/floorplan/items.ts index 8f999e56..40d290a5 100644 --- a/packages/editor/src/lib/floorplan/items.ts +++ b/packages/editor/src/lib/floorplan/items.ts @@ -139,6 +139,21 @@ export function buildFloorplanItemEntry( return null } + const dimensionPolygon = getItemDimensionPolygon(item, transform) + const [width, , depth] = getScaledDimensions(item) + if (shouldUseDimensionFloorplanFootprint(item)) { + return { + dimensionPolygon, + item, + polygon: dimensionPolygon, + usesRealMesh: true, + center: transform.position, + rotation: transform.rotation, + width, + depth, + } + } + const object = sceneRegistry.nodes.get(item.id) const realMeshPolygon = object ? getRealMeshFloorplanPolygon(transform, object) @@ -147,9 +162,6 @@ export function buildFloorplanItemEntry( return null } - const dimensionPolygon = getItemDimensionPolygon(item, transform) - const [width, , depth] = getScaledDimensions(item) - return { dimensionPolygon, item, @@ -167,6 +179,29 @@ type Point = { y: number } +const DIMENSION_FOOTPRINT_ASSET_IDS = new Set(['tree', 'fir-tree', 'palm', 'bush']) +const DIMENSION_FOOTPRINT_TAGS = new Set([ + 'botanical', + 'foliage', + 'greenery', + 'plant', + 'tree', + 'vegetation', +]) + +function shouldUseDimensionFloorplanFootprint(item: ItemNode) { + const asset = item.asset + if (asset.category !== 'outdoor') { + return false + } + + if (DIMENSION_FOOTPRINT_ASSET_IDS.has(asset.id)) { + return true + } + + return asset.tags?.some((tag) => DIMENSION_FOOTPRINT_TAGS.has(tag.toLowerCase())) ?? false +} + function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] { const [width, , depth] = getScaledDimensions(item) const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0 From 411ae2e905b2ce606f1484063728ce110d9ff2d2 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 29 Apr 2026 17:51:58 +0530 Subject: [PATCH 02/13] Refine 2D door swing rendering --- .../public/items/office-chair/floor-plan.svg | 9 + apps/editor/public/items/sofa/floor-plan.svg | 11 + packages/core/src/index.ts | 14 +- .../core/src/systems/slab/slab-system.tsx | 10 +- .../editor-2d/floorplan-action-menu-layer.tsx | 2 + .../renderers/floorplan-draft-layer.tsx | 20 +- .../renderers/floorplan-roof-layer.tsx | 31 +- .../renderers/floorplan-stair-layer.tsx | 100 +- .../src/components/editor/floorplan-panel.tsx | 2819 ++++++++++++++--- .../ui/item-catalog/catalog-items.tsx | 4 +- .../src/components/ui/viewer-toolbar.tsx | 43 +- packages/editor/src/lib/floorplan/stairs.ts | 25 +- 12 files changed, 2600 insertions(+), 488 deletions(-) create mode 100644 apps/editor/public/items/office-chair/floor-plan.svg create mode 100644 apps/editor/public/items/sofa/floor-plan.svg 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,19 +6980,54 @@ 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 - const shouldShowZoneBoundaryHandles = canSelectFloorplanZones && selectedZoneEntry !== null - const showZonePolygons = true // Zone polygons always visible (labels always clickable) - const visibleZonePolygons = displayZonePolygons - const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) - const highlightedFloorplanIdSet = useMemo( + 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 + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + const highlightedFloorplanIdSet = useMemo( () => new Set([...selectedIds, ...previewSelectedIds]), [previewSelectedIds, selectedIds], ) @@ -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) => ({ + const rawPolygon = selectedSlabEntry.polygon + + return getSlabHandlePolygon(selectedSlabEntry).map((point) => { + const vertexIndex = getClosestPolygonVertexIndex(point, rawPolygon) + + return { + nodeId: selectedSlabEntry.slab.id, + vertexIndex, + point: toWallPlanPoint(point), + isActive: + slabVertexDragState?.slabId === selectedSlabEntry.slab.id && + slabVertexDragState.vertexIndex === vertexIndex, + } + }) + }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) + const slabMidpointHandles = useMemo(() => { + if (!(shouldShowSlabBoundaryHandles && !slabVertexDragState)) { + return [] + } + + const handlePolygon = getSlabHandlePolygon(selectedSlabEntry) + + return handlePolygon.map((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + const midpoint = { + x: (point.x + (nextPoint?.x ?? point.x)) / 2, + y: (point.y + (nextPoint?.y ?? point.y)) / 2, + } + + return { + nodeId: selectedSlabEntry.slab.id, + edgeIndex, + point: [midpoint.x, midpoint.y] as WallPlanPoint, + } + }) + }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) + const ceilingVertexHandles = useMemo(() => { + if (!shouldShowCeilingBoundaryHandles) { + return [] + } + + return selectedCeilingEntry.polygon.map((point, vertexIndex) => ({ + nodeId: selectedCeilingEntry.ceiling.id, + vertexIndex, + point: toWallPlanPoint(point), + isActive: + ceilingVertexDragState?.ceilingId === selectedCeilingEntry.ceiling.id && + ceilingVertexDragState.vertexIndex === vertexIndex, + })) + }, [ceilingVertexDragState, selectedCeilingEntry, shouldShowCeilingBoundaryHandles]) + const ceilingMidpointHandles = useMemo(() => { + if (!(shouldShowCeilingBoundaryHandles && !ceilingVertexDragState)) { + return [] + } + + return selectedCeilingEntry.polygon.map((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + + return { + nodeId: selectedCeilingEntry.ceiling.id, + edgeIndex, + point: [ + (point.x + (nextPoint?.x ?? point.x)) / 2, + (point.y + (nextPoint?.y ?? point.y)) / 2, + ] as WallPlanPoint, + } + }) + }, [ceilingVertexDragState, selectedCeilingEntry, shouldShowCeilingBoundaryHandles]) + const slabHoleVertexHandles = useMemo(() => { + if ( + !( + shouldShowSlabHoleBoundaryHandles && + selectedSlabEntry && + selectedSlabEditingHole && + selectedSlabEditingHoleIndex !== null + ) + ) { + return [] + } + + return selectedSlabEditingHole.map((point, vertexIndex) => ({ nodeId: selectedSlabEntry.slab.id, vertexIndex, point: toWallPlanPoint(point), isActive: - slabVertexDragState?.slabId === selectedSlabEntry.slab.id && - slabVertexDragState.vertexIndex === vertexIndex, + slabHoleVertexDragState?.slabId === selectedSlabEntry.slab.id && + slabHoleVertexDragState.holeIndex === selectedSlabEditingHoleIndex && + slabHoleVertexDragState.vertexIndex === vertexIndex, })) - }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) - const slabMidpointHandles = useMemo(() => { - if (!(shouldShowSlabBoundaryHandles && !slabVertexDragState)) { + }, [ + selectedSlabEditingHole, + selectedSlabEditingHoleIndex, + selectedSlabEntry, + shouldShowSlabHoleBoundaryHandles, + slabHoleVertexDragState, + ]) + const slabHoleMidpointHandles = useMemo(() => { + if ( + !( + shouldShowSlabHoleBoundaryHandles && + selectedSlabEntry && + selectedSlabEditingHole && + !slabHoleVertexDragState + ) + ) { return [] } - return selectedSlabEntry.polygon.map((point, edgeIndex, polygon) => { + return selectedSlabEditingHole.map((point, edgeIndex, polygon) => { const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + return { nodeId: selectedSlabEntry.slab.id, edgeIndex, @@ -6717,7 +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) { @@ -8540,7 +9293,7 @@ export function FloorplanPanel() { ]) useEffect(() => { - const dragState = zoneVertexDragState + const dragState = ceilingVertexDragState if (!dragState) { return } @@ -8560,8 +9313,8 @@ export function FloorplanPanel() { const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] setCursorPoint(snappedPoint) - setZoneBoundaryDraft((currentDraft) => { - if (!currentDraft || currentDraft.zoneId !== dragState.zoneId) { + setCeilingBoundaryDraft((currentDraft) => { + if (!currentDraft || currentDraft.ceilingId !== dragState.ceilingId) { return currentDraft } @@ -8582,14 +9335,14 @@ export function FloorplanPanel() { }) } - const commitZoneVertexDrag = (event: PointerEvent) => { + const commitCeilingVertexDrag = (event: PointerEvent) => { if (event.pointerId !== dragState.pointerId) { return } - const draft = zoneBoundaryDraftRef.current - const zone = zoneById.get(dragState.zoneId) - if (draft && zone && !polygonsEqual(draft.polygon, zone.polygon)) { + const draft = ceilingBoundaryDraftRef.current + const ceiling = ceilingById.get(dragState.ceilingId) + if (draft && ceiling && !polygonsEqual(draft.polygon, ceiling.polygon)) { const suppressClick = (clickEvent: MouseEvent) => { clickEvent.stopImmediatePropagation() clickEvent.preventDefault() @@ -8600,94 +9353,559 @@ export function FloorplanPanel() { window.removeEventListener('click', suppressClick, true) }) - updateNode(draft.zoneId, { + updateNode(draft.ceilingId, { polygon: draft.polygon, }) sfxEmitter.emit('sfx:structure-build') } - clearZoneBoundaryInteraction() + clearCeilingBoundaryInteraction() setCursorPoint(null) } - const cancelZoneVertexDrag = (event: PointerEvent) => { + const cancelCeilingVertexDrag = (event: PointerEvent) => { if (event.pointerId !== dragState.pointerId) { return } - clearZoneBoundaryInteraction() + clearCeilingBoundaryInteraction() setCursorPoint(null) } window.addEventListener('pointermove', handleWindowPointerMove) - window.addEventListener('pointerup', commitZoneVertexDrag) - window.addEventListener('pointercancel', cancelZoneVertexDrag) + window.addEventListener('pointerup', commitCeilingVertexDrag) + window.addEventListener('pointercancel', cancelCeilingVertexDrag) return () => { window.removeEventListener('pointermove', handleWindowPointerMove) - window.removeEventListener('pointerup', commitZoneVertexDrag) - window.removeEventListener('pointercancel', cancelZoneVertexDrag) + window.removeEventListener('pointerup', commitCeilingVertexDrag) + window.removeEventListener('pointercancel', cancelCeilingVertexDrag) } }, [ - clearZoneBoundaryInteraction, + ceilingById, + ceilingVertexDragState, + clearCeilingBoundaryInteraction, getPlanPointFromClientPoint, updateNode, - zoneById, - zoneVertexDragState, ]) useEffect(() => { - return () => { - setFloorplanHovered(false) - } - }, [setFloorplanHovered]) - - const handlePointerDown = useCallback((event: ReactPointerEvent) => { - if (event.button !== 2) { + const dragState = slabHoleVertexDragState + if (!dragState) { return } - event.preventDefault() - event.stopPropagation() + const handleWindowPointerMove = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } - panStateRef.current = { - pointerId: event.pointerId, - clientX: event.clientX, - clientY: event.clientY, - } - setIsPanning(true) + event.preventDefault() - event.currentTarget.setPointerCapture(event.pointerId) - }, []) + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } - const endPanning = useCallback((event?: ReactPointerEvent) => { - if (event && panStateRef.current && event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId) - } + const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + setCursorPoint(snappedPoint) - panStateRef.current = null - setIsPanning(false) - }, []) + setSlabHoleBoundaryDraft((currentDraft) => { + if ( + !currentDraft || + currentDraft.slabId !== dragState.slabId || + currentDraft.holeIndex !== dragState.holeIndex + ) { + return currentDraft + } - const hoveredWallIdRef = useRef(null) - const floorplanGridLocalY = useMemo(() => { - if (movingNode?.type === 'item') { - return movingNode.position[1] - } + const currentPoint = currentDraft.polygon[dragState.vertexIndex] + if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { + return currentDraft + } - if (levelId) { - return sceneRegistry.nodes.get(levelId as AnyNodeId)?.position.y ?? 0 - } + sfxEmitter.emit('sfx:grid-snap') - return 0 - }, [levelId, movingNode]) - const floorplanGridWorldY = buildingPosition[1] + floorplanGridLocalY - const emitFloorplanWallLeave = useCallback((wallId: string | null) => { - if (!wallId) { - return - } + const nextPolygon = [...currentDraft.polygon] + nextPolygon[dragState.vertexIndex] = snappedPoint - const wallNode = useScene.getState().nodes[wallId as AnyNodeId] + 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) { + return + } + + const handleWindowPointerMove = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + event.preventDefault() + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } + + const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + setCursorPoint(snappedPoint) + + setZoneBoundaryDraft((currentDraft) => { + if (!currentDraft || currentDraft.zoneId !== dragState.zoneId) { + return currentDraft + } + + const currentPoint = currentDraft.polygon[dragState.vertexIndex] + if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { + return currentDraft + } + + sfxEmitter.emit('sfx:grid-snap') + + const nextPolygon = [...currentDraft.polygon] + nextPolygon[dragState.vertexIndex] = snappedPoint + + return { + ...currentDraft, + polygon: nextPolygon, + } + }) + } + + const commitZoneVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + const draft = zoneBoundaryDraftRef.current + const zone = zoneById.get(dragState.zoneId) + if (draft && zone && !polygonsEqual(draft.polygon, zone.polygon)) { + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + updateNode(draft.zoneId, { + polygon: draft.polygon, + }) + sfxEmitter.emit('sfx:structure-build') + } + + clearZoneBoundaryInteraction() + setCursorPoint(null) + } + + const cancelZoneVertexDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) { + return + } + + clearZoneBoundaryInteraction() + setCursorPoint(null) + } + + window.addEventListener('pointermove', handleWindowPointerMove) + window.addEventListener('pointerup', commitZoneVertexDrag) + window.addEventListener('pointercancel', cancelZoneVertexDrag) + + return () => { + window.removeEventListener('pointermove', handleWindowPointerMove) + window.removeEventListener('pointerup', commitZoneVertexDrag) + window.removeEventListener('pointercancel', cancelZoneVertexDrag) + } + }, [ + clearZoneBoundaryInteraction, + getPlanPointFromClientPoint, + updateNode, + zoneById, + zoneVertexDragState, + ]) + + useEffect(() => { + return () => { + setFloorplanHovered(false) + } + }, [setFloorplanHovered]) + + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 2) { + return + } + + event.preventDefault() + event.stopPropagation() + + panStateRef.current = { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + } + setIsPanning(true) + + event.currentTarget.setPointerCapture(event.pointerId) + }, []) + + const endPanning = useCallback((event?: ReactPointerEvent) => { + if (event && panStateRef.current && event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + + panStateRef.current = null + setIsPanning(false) + }, []) + + const hoveredWallIdRef = useRef(null) + const floorplanGridLocalY = useMemo(() => { + if (movingNode?.type === 'item') { + return movingNode.position[1] + } + + if (levelId) { + return sceneRegistry.nodes.get(levelId as AnyNodeId)?.position.y ?? 0 + } + + return 0 + }, [levelId, movingNode]) + const floorplanGridWorldY = buildingPosition[1] + floorplanGridLocalY + const emitFloorplanWallLeave = useCallback((wallId: string | null) => { + if (!wallId) { + return + } + + const wallNode = useScene.getState().nodes[wallId as AnyNodeId] if (!wallNode || wallNode.type !== 'wall') { return } @@ -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, @@ -9972,61 +11215,271 @@ export function FloorplanPanel() { (event: ReactMouseEvent) => { event.stopPropagation() - const wall = selectedWallEntry?.wall - if (!wall) { + const wall = selectedWallEntry?.wall + if (!wall) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(wall.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedWallEntry, setSelection], + ) + const handleSelectedSlabMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const slab = selectedSlabEntry?.slab + if (!slab) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(slab) + setSelection({ selectedIds: [] }) + }, + [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() + + const slab = selectedSlabEntry?.slab + if (!slab) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(slab.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedSlabEntry, setSelection], + ) + const handleSelectedCeilingMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const ceiling = selectedCeilingEntry?.ceiling + if (!ceiling) { return } - sfxEmitter.emit('sfx:item-delete') - deleteNode(wall.id as AnyNodeId) + sfxEmitter.emit('sfx:item-pick') + setMovingNode(ceiling) setSelection({ selectedIds: [] }) }, - [deleteNode, selectedWallEntry, setSelection], + [selectedCeilingEntry, setMovingNode, setSelection], ) - const handleSelectedSlabMove = useCallback( + const handleSelectedCeilingAddHole = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() - const slab = selectedSlabEntry?.slab - if (!slab) { + const ceiling = selectedCeilingEntry?.ceiling + if (!(ceiling && ceiling.polygon.length > 0)) { return } - sfxEmitter.emit('sfx:item-pick') - setMovingNode(slab) - setSelection({ selectedIds: [] }) + 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') }, - [selectedSlabEntry, setMovingNode, setSelection], + [selectedCeilingEntry, setEditingHole, updateNode], ) - const handleSelectedSlabDelete = useCallback( + const handleSelectedCeilingHoleMove = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() - const slab = selectedSlabEntry?.slab - if (!slab) { + const ceiling = selectedCeilingEntry?.ceiling + const holeIndex = selectedCeilingEditingHoleIndex + const hole = selectedCeilingEditingHole + if (!(ceiling && holeIndex !== null && hole && hole.length > 0)) { return } - sfxEmitter.emit('sfx:item-delete') - deleteNode(slab.id as AnyNodeId) - setSelection({ selectedIds: [] }) + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + const [sumX, sumY] = hole.reduce( + ([currentX, currentY], point) => [currentX + point.x, currentY + point.y], + [0, 0], + ) + const startPlanPoint = + planPoint ?? ([sumX / hole.length, sumY / hole.length] as WallPlanPoint) + const originalPolygon = hole.map(toWallPlanPoint) + + setCeilingHoleBoundaryDraft(null) + setCeilingHoleVertexDragState(null) + setCeilingHoleMoveDraft({ + ceilingId: ceiling.id, + holeIndex, + polygon: originalPolygon, + originalPolygon, + startPlanPoint, + }) + setCursorPoint(startPlanPoint) + sfxEmitter.emit('sfx:item-pick') }, - [deleteNode, selectedSlabEntry, setSelection], + [ + getPlanPointFromClientPoint, + selectedCeilingEditingHole, + selectedCeilingEditingHoleIndex, + selectedCeilingEntry, + ], ) - const handleSelectedCeilingMove = useCallback( + const handleSelectedCeilingHoleDelete = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() const ceiling = selectedCeilingEntry?.ceiling - if (!ceiling) { + const holeIndex = selectedCeilingEditingHoleIndex + if (!(ceiling && holeIndex !== null)) { return } - sfxEmitter.emit('sfx:item-pick') - setMovingNode(ceiling) - setSelection({ selectedIds: [] }) + const currentHoles = ceiling.holes ?? [] + if (!currentHoles[holeIndex] || ceiling.holeMetadata?.[holeIndex]?.source === 'stair') { + return + } + + const currentMetadata = currentHoles.map( + (_, index) => ceiling.holeMetadata?.[index] ?? { source: 'manual' as const }, + ) + updateNode(ceiling.id, { + holes: currentHoles.filter((_, index) => index !== holeIndex), + holeMetadata: currentMetadata.filter((_, index) => index !== holeIndex), + }) + setEditingHole(null) + setCeilingHoleBoundaryDraft(null) + setCeilingHoleMoveDraft(null) + setCeilingHoleVertexDragState(null) + sfxEmitter.emit('sfx:item-delete') }, - [selectedCeilingEntry, setMovingNode, setSelection], + [selectedCeilingEditingHoleIndex, selectedCeilingEntry, setEditingHole, updateNode], ) const handleSelectedCeilingDelete = useCallback( (event: ReactMouseEvent) => { @@ -10304,112 +11757,361 @@ export function FloorplanPanel() { event.stopPropagation() duplicateSelectedRoof() }, - [duplicateSelectedRoof], + [duplicateSelectedRoof], + ) + const handleSelectedRoofDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const roof = selectedRoofEntry?.roof + if (!roof) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(roof.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedRoofEntry, setSelection], + ) + + const handleWallEndpointPointerDown = useCallback( + (wall: WallNode, endpoint: WallEndpoint, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredEndpointId(null) + + const movingPoint = endpoint === 'start' ? wall.start : wall.end + + if (isWallBuildActive) { + handleWallPlacementPoint(movingPoint) + return + } + + if (mode !== 'select') { + return + } + + clearWallPlacementDraft() + handleWallSelect(wall) + + const fixedPoint = endpoint === 'start' ? wall.end : wall.start + const originalStart = [...wall.start] as WallPlanPoint + const originalEnd = [...wall.end] as WallPlanPoint + const linkedWalls = getLinkedWallSnapshots(walls, wall.id, originalStart, originalEnd) + + wallEndpointDragRef.current = { + pointerId: event.pointerId, + wallId: wall.id, + endpoint, + fixedPoint, + currentPoint: movingPoint, + originalStart, + originalEnd, + linkedWalls, + } + + setWallEndpointDraft( + buildWallEndpointDraft(wall.id, endpoint, fixedPoint, movingPoint, linkedWalls), + ) + setCursorPoint(movingPoint) + }, + [ + clearWallPlacementDraft, + handleWallPlacementPoint, + handleWallSelect, + isWallBuildActive, + mode, + walls, + ], + ) + const handleWallCurvePointerDown = useCallback( + (wall: WallNode, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredWallCurveHandleId(null) + + if (isWallBuildActive || mode !== 'select') { + return + } + + clearWallPlacementDraft() + handleWallSelect(wall) + clearWallEndpointDrag() + + const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0) + wallCurveDragRef.current = { + pointerId: event.pointerId, + wallId: wall.id, + currentCurveOffset, + } + setWallCurveDraft({ + wallId: wall.id, + curveOffset: currentCurveOffset, + }) + const center = getWallMidpointHandlePoint(wall) + setCursorPoint([center.x, center.y]) + }, + [clearWallEndpointDrag, clearWallPlacementDraft, handleWallSelect, isWallBuildActive, mode], + ) + const handleSlabVertexPointerDown = useCallback( + (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredSlabHandleId(null) + + const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) + const vertexPoint = slabEntry?.polygon[vertexIndex] + const handlePolygon = slabEntry ? getSlabHandlePolygon(slabEntry) : [] + const handlePoint = + vertexPoint && handlePolygon.length > 0 + ? handlePolygon[getClosestPolygonVertexIndex(vertexPoint, handlePolygon)] + : null + if (!(slabEntry && vertexPoint && handlePoint)) { + return + } + + const visualOffsets = getSlabVisualOffsets(slabEntry) + + setSlabBoundaryDraft({ + slabId, + polygon: slabEntry.polygon.map(toWallPlanPoint), + visualOffsets, + }) + setSlabVertexDragState({ + pointerId: event.pointerId, + slabId, + vertexIndex, + visualOffset: { + x: handlePoint.x - vertexPoint.x, + y: handlePoint.y - vertexPoint.y, + }, + }) + setCursorPoint(toWallPlanPoint(handlePoint)) + }, + [displaySlabPolygons], + ) + const handleSlabVertexDoubleClick = useCallback( + (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + + const slab = slabById.get(slabId) + if (!(slab && slab.polygon.length > 3)) { + return + } + + slabBoundaryDraftRef.current = null + clearSlabBoundaryInteraction() + + updateNode(slabId, { + polygon: slab.polygon.filter((_, index) => index !== vertexIndex), + }) + }, + [clearSlabBoundaryInteraction, slabById, updateNode], + ) + const handleSlabMidpointPointerDown = useCallback( + ( + slabId: SlabNode['id'], + handleEdgeIndex: number, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredSlabHandleId(null) + + const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) + if (!slabEntry) { + return + } + + const basePolygon = slabEntry.polygon.map(toWallPlanPoint) + const handlePolygon = getSlabHandlePolygon(slabEntry) + const handleStartPoint = handlePolygon[handleEdgeIndex] + const handleEndPoint = handlePolygon[(handleEdgeIndex + 1) % handlePolygon.length] + const insertedHandlePoint: WallPlanPoint = + handleStartPoint && handleEndPoint + ? [ + (handleStartPoint.x + handleEndPoint.x) / 2, + (handleStartPoint.y + handleEndPoint.y) / 2, + ] + : (basePolygon[handleEdgeIndex] ?? basePolygon[0] ?? ([0, 0] as WallPlanPoint)) + const edgeIndex = getClosestPolygonEdgeIndex( + toPoint2D(insertedHandlePoint), + slabEntry.polygon, + ) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { + return + } + const insertedPoint: WallPlanPoint = [ + (startPoint[0] + endPoint[0]) / 2, + (startPoint[1] + endPoint[1]) / 2, + ] + const insertIndex = edgeIndex + 1 + const nextPolygon = [ + ...basePolygon.slice(0, insertIndex), + insertedPoint, + ...basePolygon.slice(insertIndex), + ] + const visualOffsets = getSlabVisualOffsets(slabEntry) + const insertedVisualOffset = { + x: insertedHandlePoint[0] - insertedPoint[0], + y: insertedHandlePoint[1] - insertedPoint[1], + } + const nextVisualOffsets = [ + ...visualOffsets.slice(0, insertIndex), + insertedVisualOffset, + ...visualOffsets.slice(insertIndex), + ] + + setSlabBoundaryDraft({ + slabId, + polygon: nextPolygon, + visualOffsets: nextVisualOffsets, + }) + setSlabVertexDragState({ + pointerId: event.pointerId, + slabId, + vertexIndex: insertIndex, + visualOffset: insertedVisualOffset, + }) + setCursorPoint(insertedHandlePoint) + }, + [displaySlabPolygons], ) - const handleSelectedRoofDelete = useCallback( - (event: ReactMouseEvent) => { + const handleCeilingVertexPointerDown = useCallback( + ( + ceilingId: CeilingNode['id'], + vertexIndex: number, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) { + return + } + + event.preventDefault() event.stopPropagation() + setHoveredCeilingHandleId(null) - const roof = selectedRoofEntry?.roof - if (!roof) { + const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) + const vertexPoint = ceilingEntry?.polygon[vertexIndex] + if (!(ceilingEntry && vertexPoint)) { return } - sfxEmitter.emit('sfx:item-delete') - deleteNode(roof.id as AnyNodeId) - setSelection({ selectedIds: [] }) + setCeilingBoundaryDraft({ + ceilingId, + polygon: ceilingEntry.polygon.map(toWallPlanPoint), + }) + setCeilingVertexDragState({ + pointerId: event.pointerId, + ceilingId, + vertexIndex, + }) + setCursorPoint(toWallPlanPoint(vertexPoint)) }, - [deleteNode, selectedRoofEntry, setSelection], + [displayCeilingPolygons], ) - - const handleWallEndpointPointerDown = useCallback( - (wall: WallNode, endpoint: WallEndpoint, event: ReactPointerEvent) => { + const handleCeilingVertexDoubleClick = useCallback( + ( + ceilingId: CeilingNode['id'], + vertexIndex: number, + event: ReactPointerEvent, + ) => { if (event.button !== 0) { return } event.preventDefault() event.stopPropagation() - setHoveredEndpointId(null) - const movingPoint = endpoint === 'start' ? wall.start : wall.end - - if (isWallBuildActive) { - handleWallPlacementPoint(movingPoint) - return - } - - if (mode !== 'select') { + const ceiling = ceilingById.get(ceilingId) + if (!(ceiling && ceiling.polygon.length > 3)) { return } - clearWallPlacementDraft() - handleWallSelect(wall) - - const fixedPoint = endpoint === 'start' ? wall.end : wall.start - const originalStart = [...wall.start] as WallPlanPoint - const originalEnd = [...wall.end] as WallPlanPoint - const linkedWalls = getLinkedWallSnapshots(walls, wall.id, originalStart, originalEnd) - - wallEndpointDragRef.current = { - pointerId: event.pointerId, - wallId: wall.id, - endpoint, - fixedPoint, - currentPoint: movingPoint, - originalStart, - originalEnd, - linkedWalls, - } + ceilingBoundaryDraftRef.current = null + clearCeilingBoundaryInteraction() - setWallEndpointDraft( - buildWallEndpointDraft(wall.id, endpoint, fixedPoint, movingPoint, linkedWalls), - ) - setCursorPoint(movingPoint) + updateNode(ceilingId, { + polygon: ceiling.polygon.filter((_, index) => index !== vertexIndex), + }) }, - [ - clearWallPlacementDraft, - handleWallPlacementPoint, - handleWallSelect, - isWallBuildActive, - mode, - walls, - ], + [ceilingById, clearCeilingBoundaryInteraction, updateNode], ) - const handleWallCurvePointerDown = useCallback( - (wall: WallNode, event: ReactPointerEvent) => { + const handleCeilingMidpointPointerDown = useCallback( + ( + ceilingId: CeilingNode['id'], + edgeIndex: number, + event: ReactPointerEvent, + ) => { if (event.button !== 0) { return } event.preventDefault() event.stopPropagation() - setHoveredWallCurveHandleId(null) + setHoveredCeilingHandleId(null) - if (isWallBuildActive || mode !== 'select') { + const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) + if (!ceilingEntry) { return } - clearWallPlacementDraft() - handleWallSelect(wall) - clearWallEndpointDrag() + const basePolygon = ceilingEntry.polygon.map(toWallPlanPoint) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { + return + } - const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0) - wallCurveDragRef.current = { + 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), + ] + + setCeilingBoundaryDraft({ + ceilingId, + polygon: nextPolygon, + }) + setCeilingVertexDragState({ pointerId: event.pointerId, - wallId: wall.id, - currentCurveOffset, - } - setWallCurveDraft({ - wallId: wall.id, - curveOffset: currentCurveOffset, + ceilingId, + vertexIndex: insertIndex, }) - const center = getWallMidpointHandlePoint(wall) - setCursorPoint([center.x, center.y]) + setCursorPoint(insertedPoint) }, - [clearWallEndpointDrag, clearWallPlacementDraft, handleWallSelect, isWallBuildActive, mode], + [displayCeilingPolygons], ) - const handleSlabVertexPointerDown = useCallback( + const handleSlabHoleVertexPointerDown = useCallback( (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { return @@ -10420,25 +12122,29 @@ export function FloorplanPanel() { setHoveredSlabHandleId(null) const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - const vertexPoint = slabEntry?.polygon[vertexIndex] - if (!(slabEntry && vertexPoint)) { + const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? slabEntry?.holes[holeIndex] : null + const vertexPoint = hole?.[vertexIndex] + if (!(slabEntry && holeIndex !== null && hole && vertexPoint)) { return } - setSlabBoundaryDraft({ + setSlabHoleBoundaryDraft({ slabId, - polygon: slabEntry.polygon.map(toWallPlanPoint), + holeIndex, + polygon: hole.map(toWallPlanPoint), }) - setSlabVertexDragState({ + setSlabHoleVertexDragState({ pointerId: event.pointerId, slabId, + holeIndex, vertexIndex, }) setCursorPoint(toWallPlanPoint(vertexPoint)) }, - [displaySlabPolygons], + [displaySlabPolygons, editingHole], ) - const handleSlabVertexDoubleClick = useCallback( + const handleSlabHoleVertexDoubleClick = useCallback( (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { return @@ -10448,20 +12154,24 @@ export function FloorplanPanel() { event.stopPropagation() const slab = slabById.get(slabId) - if (!(slab && slab.polygon.length > 3)) { + 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 } - slabBoundaryDraftRef.current = null - clearSlabBoundaryInteraction() + slabHoleBoundaryDraftRef.current = null + clearSlabHoleBoundaryInteraction() + const nextHoles = [...(slab.holes ?? [])] + nextHoles[holeIndex] = hole.filter((_, index) => index !== vertexIndex) updateNode(slabId, { - polygon: slab.polygon.filter((_, index) => index !== vertexIndex), + holes: nextHoles, }) }, - [clearSlabBoundaryInteraction, slabById, updateNode], + [clearSlabHoleBoundaryInteraction, editingHole, slabById, updateNode], ) - const handleSlabMidpointPointerDown = useCallback( + const handleSlabHoleMidpointPointerDown = useCallback( (slabId: SlabNode['id'], edgeIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { return @@ -10472,11 +12182,13 @@ export function FloorplanPanel() { setHoveredSlabHandleId(null) const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) - if (!slabEntry) { + const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? slabEntry?.holes[holeIndex] : null + if (!(slabEntry && holeIndex !== null && hole)) { return } - const basePolygon = slabEntry.polygon.map(toWallPlanPoint) + const basePolygon = hole.map(toWallPlanPoint) const startPoint = basePolygon[edgeIndex] const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] if (!(startPoint && endPoint)) { @@ -10494,18 +12206,142 @@ export function FloorplanPanel() { ...basePolygon.slice(insertIndex), ] - setSlabBoundaryDraft({ + setSlabHoleBoundaryDraft({ slabId, + holeIndex, polygon: nextPolygon, }) - setSlabVertexDragState({ + setSlabHoleVertexDragState({ pointerId: event.pointerId, slabId, + holeIndex, vertexIndex: insertIndex, }) setCursorPoint(insertedPoint) }, - [displaySlabPolygons], + [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], From 3d0000f93f693f7436ffe86c883d69d74a0e2b8e Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Apr 2026 10:11:39 +0530 Subject: [PATCH 03/13] Disable pointer events on item placement labels --- .../components/tools/item/use-placement-coordinator.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 1fb6fe66..b221b1b1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1283,7 +1283,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea > - +
{widthLabel}
- +
{depthLabel}
- +
From 8631622e607cc8766b49413cad41cac633888891 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Apr 2026 13:37:52 +0530 Subject: [PATCH 04/13] Refactor editor workflow and update UI state handling --- packages/core/src/events/bus.ts | 10 +- packages/core/src/schema/index.ts | 6 +- packages/core/src/schema/nodes/guide.ts | 14 + .../src/components/editor/floorplan-panel.tsx | 791 ++++++++++++++++-- .../ui/action-menu/view-toggles.tsx | 180 +++- .../components/ui/panels/reference-panel.tsx | 240 +++++- .../ui/sidebar/panels/site-panel/index.tsx | 50 +- packages/editor/src/lib/local-guide-image.ts | 44 + .../renderers/guide/guide-renderer.tsx | 2 +- 9 files changed, 1229 insertions(+), 108 deletions(-) create mode 100644 packages/editor/src/lib/local-guide-image.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index b313008b..105bb590 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,11 +1,12 @@ import type { ThreeEvent } from '@react-three/fiber' -import type { Object3D } from 'three' import mitt from 'mitt' +import type { Object3D } from 'three' import type { BuildingNode, CeilingNode, DoorNode, FenceNode, + GuideNode, ItemNode, LevelNode, RoofNode, @@ -130,6 +131,12 @@ type ToolEvents = { 'tool:cancel': undefined } +type GuideEvents = { + 'guide:set-reference-scale': { guideId: GuideNode['id'] } + 'guide:cancel-reference-scale': undefined + 'guide:deleted': { guideId: GuideNode['id'] } +} + type PresetEvents = { 'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } @@ -169,6 +176,7 @@ type EditorEvents = GridEvents & NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents & + GuideEvents & PresetEvents & ThumbnailEvents & SnapshotEvents & diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 1383216d..b183fef8 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -28,7 +28,7 @@ export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { DoorNode, DoorSegment } from './nodes/door' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' -export { GuideNode } from './nodes/guide' +export { GuideNode, GuideScaleReference } from './nodes/guide' export type { AnimationEffect, Asset, @@ -43,13 +43,14 @@ export type { } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item' export { LevelNode } from './nodes/level' -export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' +export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' +export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { getEffectiveStairSurfaceMaterial, StairNode, @@ -58,7 +59,6 @@ export { StairTopLandingMode, StairType, } from './nodes/stair' -export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' diff --git a/packages/core/src/schema/nodes/guide.ts b/packages/core/src/schema/nodes/guide.ts index 359887e9..8d75af9a 100644 --- a/packages/core/src/schema/nodes/guide.ts +++ b/packages/core/src/schema/nodes/guide.ts @@ -2,6 +2,16 @@ import { z } from 'zod' import { AssetUrl } from '../asset-url' import { BaseNode, nodeType, objectId } from '../base' +export const GuideScaleReference = z.object({ + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + realLengthMeters: z.number().positive(), + measuredLengthUnits: z.number().positive(), + metersPerUnit: z.number().positive(), + label: z.string(), + visible: z.boolean().default(true), +}) + export const GuideNode = BaseNode.extend({ id: objectId('guide'), type: nodeType('guide'), @@ -10,6 +20,10 @@ export const GuideNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), scale: z.number().default(1), opacity: z.number().min(0).max(100).default(50), + locked: z.boolean().default(false), + showIn3d: z.boolean().default(false), + scaleReference: GuideScaleReference.nullable().default(null), }) +export type GuideScaleReference = z.infer export type GuideNode = z.infer diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 8d634076..a2e01262 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -42,7 +42,7 @@ import { type ZoneNode as ZoneNodeType, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Command } from 'lucide-react' +import { Command, Ruler } from 'lucide-react' import { memo, type MouseEvent as ReactMouseEvent, @@ -204,6 +204,9 @@ const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92 const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48 const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45 const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1 +const FLOORPLAN_TRACE_SURFACE_FILL_OPACITY = 0.08 +const FLOORPLAN_TRACE_STRUCTURE_FILL_OPACITY = 0.22 +const FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY = 0.34 const FLOORPLAN_SITE_COLOR = '#10b981' const FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH = FLOORPLAN_OPENING_STROKE_WIDTH / 2 const FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH = FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH * 0.7 @@ -336,6 +339,21 @@ type GuideTransformDraft = { rotation: number } +type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches' + +type ReferenceScaleDraft = { + guideId: GuideNode['id'] + start: WallPlanPoint | null + cursor: WallPlanPoint | null +} + +type PendingReferenceScale = { + guideId: GuideNode['id'] + start: WallPlanPoint + end: WallPlanPoint + measuredLengthUnits: number +} + type GuideHandleHintAnchor = { x: number y: number @@ -882,6 +900,14 @@ function getGuideRotateCursor(isDarkMode: boolean) { return buildCursorUrl(svgMarkup, 12, 12, 'pointer') } +function getGuideSvgRotation(rotationY: number) { + return normalizeAngle(Math.PI - rotationY) +} + +function getGuideSceneRotationFromSvgRotation(rotationSvg: number) { + return normalizeAngle(Math.PI - rotationSvg) +} + function buildGuideTranslateDraft( interaction: GuideInteractionState, pointerSvg: SvgPoint, @@ -895,7 +921,7 @@ function buildGuideTranslateDraft( guideId: interaction.guideId, position: toPlanPointFromSvgPoint(centerSvg), scale: interaction.scale, - rotation: normalizeAngle(-interaction.rotationSvg), + rotation: getGuideSceneRotationFromSvgRotation(interaction.rotationSvg), } } @@ -944,6 +970,56 @@ function doesGuideMatchDraft(guide: GuideNode, draft: GuideTransformDraft, epsil ) } +function transformGuideReferencePoint( + point: WallPlanPoint, + guide: GuideNode, + draft: GuideTransformDraft, +): WallPlanPoint { + const oldCenterSvg = getGuideCenterSvgPoint(guide) + const newCenterSvg: SvgPoint = { + x: toSvgX(draft.position[0]), + y: toSvgY(draft.position[1]), + } + const oldRotationSvg = getGuideSvgRotation(guide.rotation[1]) + const newRotationSvg = getGuideSvgRotation(draft.rotation) + const oldScale = guide.scale > 0 ? guide.scale : 1 + const newScale = draft.scale > 0 ? draft.scale : oldScale + const pointSvg = toSvgPlanPoint(point) + const localUnrotated = rotateVector(subtractSvgPoints(pointSvg, oldCenterSvg), -oldRotationSvg) + const localScaled: WallPlanPoint = [ + (localUnrotated[0] / oldScale) * newScale, + (localUnrotated[1] / oldScale) * newScale, + ] + const nextSvg = addVectorToSvgPoint(newCenterSvg, rotateVector(localScaled, newRotationSvg)) + + return toPlanPointFromSvgPoint(nextSvg) +} + +function transformGuideScaleReference( + guide: GuideNode, + draft: GuideTransformDraft, +): GuideNode['scaleReference'] { + const reference = guide.scaleReference + if (!reference) { + return reference + } + + const start = transformGuideReferencePoint(reference.start, guide, draft) + const end = transformGuideReferencePoint(reference.end, guide, draft) + const measuredLengthUnits = Math.hypot(end[0] - start[0], end[1] - start[1]) + + return { + ...reference, + start, + end, + measuredLengthUnits, + metersPerUnit: + measuredLengthUnits > 0 + ? reference.realLengthMeters / measuredLengthUnits + : reference.metersPerUnit, + } +} + function buildGuideResizeDraft( interaction: GuideInteractionState, pointerSvg: SvgPoint, @@ -971,7 +1047,7 @@ function buildGuideResizeDraft( guideId: interaction.guideId, position: toPlanPointFromSvgPoint(centerSvg), scale: width / FLOORPLAN_GUIDE_BASE_WIDTH, - rotation: normalizeAngle(-interaction.rotationSvg), + rotation: getGuideSceneRotationFromSvgRotation(interaction.rotationSvg), } } @@ -987,7 +1063,7 @@ function buildGuideRotationDraft( guideId: interaction.guideId, position: toPlanPointFromSvgPoint(interaction.centerSvg), scale: interaction.scale, - rotation: normalizeAngle(-interaction.rotationSvg), + rotation: getGuideSceneRotationFromSvgRotation(interaction.rotationSvg), } } @@ -1004,7 +1080,7 @@ function buildGuideRotationDraft( guideId: interaction.guideId, position: toPlanPointFromSvgPoint(interaction.centerSvg), scale: interaction.scale, - rotation: normalizeAngle(-snappedRotationSvg), + rotation: getGuideSceneRotationFromSvgRotation(snappedRotationSvg), } } @@ -1983,15 +2059,54 @@ function getFloorplanWall(wall: WallNode): WallNode { } } -function formatMeasurement(value: number, unit: 'metric' | 'imperial') { +function formatMeasurement( + value: number, + unit: 'metric' | 'imperial', + metersPerUnit: number | null = null, +) { + const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value if (unit === 'imperial') { - const feet = value * 3.280_84 + const feet = measuredValue * 3.280_84 const wholeFeet = Math.floor(feet) const inches = Math.round((feet - wholeFeet) * 12) if (inches === 12) return `${wholeFeet + 1}'0"` return `${wholeFeet}'${inches}"` } - return `${Number.parseFloat(value.toFixed(2))}m` + return `${Number.parseFloat(measuredValue.toFixed(2))}m` +} + +function formatNumber(value: number, fractionDigits = 2) { + return Number.parseFloat(value.toFixed(fractionDigits)).toString() +} + +function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit) { + switch (unit) { + case 'centimeters': + return value / 100 + case 'feet': + return value * 0.3048 + case 'inches': + return value * 0.0254 + default: + return value + } +} + +function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) { + switch (unit) { + case 'centimeters': + return 'cm' + case 'feet': + return 'ft' + case 'inches': + return 'in' + default: + return 'm' + } +} + +function formatReferenceScaleLabel(value: number, unit: ReferenceScaleUnit) { + return `${formatNumber(value)} ${getReferenceScaleUnitLabel(unit)}` } function getPolygonAreaAndCentroid(polygon: Point2D[]) { @@ -2029,9 +2144,16 @@ function getSlabArea(polygon: Point2D[], holes: Point2D[][]) { return { area: Math.max(0, totalArea), centroid: outer.centroid } } -function formatArea(areaSqM: number, unit: 'metric' | 'imperial') { +function formatArea( + areaSqM: number, + unit: 'metric' | 'imperial', + metersPerUnit: number | null = null, +) { + const scaledAreaSqM = + metersPerUnit && metersPerUnit > 0 ? areaSqM * metersPerUnit * metersPerUnit : areaSqM + if (unit === 'imperial') { - const areaSqFt = areaSqM * 10.763_910_4 + const areaSqFt = scaledAreaSqM * 10.763_910_4 return ( <> {Math.round(areaSqFt).toLocaleString()} @@ -2044,7 +2166,7 @@ function formatArea(areaSqM: number, unit: 'metric' | 'imperial') { } return ( <> - {Number.parseFloat(areaSqM.toFixed(1))} + {Number.parseFloat(scaledAreaSqM.toFixed(1))} m 2 @@ -2058,6 +2180,7 @@ function getWallMeasurementOverlay( centerX: number, centerZ: number, unit: 'metric' | 'imperial', + metersPerUnit: number | null = null, ): LinearMeasurementOverlay | null { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] @@ -2076,7 +2199,7 @@ function getWallMeasurementOverlay( const dot = cx * nx + cz * nz const outX = dot >= 0 ? nx : -nx const outZ = dot >= 0 ? nz : -nz - const label = formatMeasurement(length, unit) + const label = formatMeasurement(length, unit, metersPerUnit) const dimensionLine = { x1: toSvgX(wall.start[0] + outX * FLOORPLAN_MEASUREMENT_OFFSET), y1: toSvgY(wall.start[1] + outZ * FLOORPLAN_MEASUREMENT_OFFSET), @@ -2420,6 +2543,7 @@ function getSelectedWallMeasurementOverlays( selectedWallEntry: WallPolygonEntry, wallPolygons: WallPolygonEntry[], unit: 'metric' | 'imperial', + metersPerUnit: number | null = null, ): LinearMeasurementOverlay[] { const { wall } = selectedWallEntry @@ -2438,7 +2562,7 @@ function getSelectedWallMeasurementOverlays( const centerX = minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2 const centerY = minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2 - const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit) + const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit, metersPerUnit) return overlay ? [overlay] : [] } @@ -2458,7 +2582,7 @@ function getSelectedWallMeasurementOverlays( const centerX = minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2 const centerY = minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2 - const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit) + const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit, metersPerUnit) return overlay ? [overlay] : [] } @@ -2478,7 +2602,7 @@ function getSelectedWallMeasurementOverlays( `${wall.id}:outer-face`, outerFace.start, outerFace.end, - formatMeasurement(outerLength, unit), + formatMeasurement(outerLength, unit, metersPerUnit), { offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, offsetVector: outwardNormal, @@ -2500,7 +2624,7 @@ function getSelectedWallMeasurementOverlays( `${wall.id}:inner-face`, innerFace.start, innerFace.end, - formatMeasurement(innerLength, unit), + formatMeasurement(innerLength, unit, metersPerUnit), { offsetDistance: FLOORPLAN_WALL_INNER_MEASUREMENT_OFFSET, offsetVector: inwardNormal, @@ -2549,7 +2673,11 @@ function getItemDimensionMeasurementOverlays( itemEntry.item.scale[2] * itemEntry.item.asset.dimensions[2], unit, ) - const buildSideOverlay = (id: string, start: Point2D, end: Point2D) => { + const buildSideOverlay = ( + id: string, + start: Point2D, + end: Point2D, + ): LinearMeasurementOverlay | null => { const edgeVector = { x: end.x - start.x, y: end.y - start.y, @@ -2601,7 +2729,7 @@ function getItemDimensionMeasurementOverlays( : null } - const widthCandidates = [ + const widthCandidates: LinearMeasurementOverlay[] = [ polygon[0] && polygon[1] ? buildSideOverlay(`${itemEntry.item.id}:width-a`, polygon[0], polygon[1]) : null, @@ -2610,7 +2738,7 @@ function getItemDimensionMeasurementOverlays( : null, ].filter((overlay): overlay is LinearMeasurementOverlay => overlay !== null) - const depthCandidates = [ + const depthCandidates: LinearMeasurementOverlay[] = [ polygon[1] && polygon[2] ? buildSideOverlay(`${itemEntry.item.id}:depth-a`, polygon[1], polygon[2]) : null, @@ -2936,7 +3064,7 @@ function FloorplanGuideImage({ const planHeight = getGuideHeight(planWidth, aspectRatio) const centerX = toSvgX(guide.position[0]) const centerY = toSvgY(guide.position[2]) - const rotationDeg = (-guide.rotation[1] * 180) / Math.PI + const rotationDeg = (getGuideSvgRotation(guide.rotation[1]) * 180) / Math.PI return ( + + + + + + + {label} + + + + ) +} + +function FloorplanReferenceScaleLayer({ + draft, + guides, + palette, + unit, + unitsPerPixel, +}: { + draft: ReferenceScaleDraft | null + guides: GuideNode[] + palette: FloorplanPalette + unit: 'metric' | 'imperial' + unitsPerPixel: number +}) { + const visibleReferences = guides + .map((guide) => guide.scaleReference) + .filter((reference): reference is NonNullable => + Boolean(reference && reference.visible !== false), + ) + + return ( + <> + {visibleReferences.map((reference, index) => ( + + ))} + {draft?.start && draft.cursor && ( + + )} + + ) +} + function FloorplanGuideSelectionOverlay({ guide, isDarkMode, @@ -3207,7 +3472,7 @@ function FloorplanGuideSelectionOverlay({ const planHeight = getGuideHeight(planWidth, aspectRatio) const centerX = toSvgX(guide.position[0]) const centerY = toSvgY(guide.position[2]) - const rotationDeg = (-guide.rotation[1] * 180) / Math.PI + const rotationDeg = (getGuideSvgRotation(guide.rotation[1]) * 180) / Math.PI const selectionStroke = isDarkMode ? '#ffffff' : '#09090b' const handleFill = isDarkMode ? '#ffffff' : '#09090b' const handleStroke = isDarkMode ? '#0a0e1b' : '#ffffff' @@ -3265,7 +3530,7 @@ function FloorplanGuideSelectionOverlay({ style={{ cursor: rotationModifierPressed ? getGuideRotateCursor(isDarkMode) - : getGuideResizeCursor(corner, -guide.rotation[1]), + : getGuideResizeCursor(corner, getGuideSvgRotation(guide.rotation[1])), }} vectorEffect="non-scaling-stroke" /> @@ -3379,6 +3644,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ wallPolygons, wallSelectionHatchId, unit, + metersPerUnit, + isGuideTraceVisible, }: { canFocusGeometry: boolean canSelectSlabs: boolean @@ -3412,11 +3679,18 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ wallPolygons: WallPolygonEntry[] wallSelectionHatchId: string unit: 'metric' | 'imperial' + metersPerUnit: number | null + isGuideTraceVisible: boolean }) { const selectedWallEntries = wallPolygons.filter(({ wall }) => selectedIdSet.has(wall.id)) const wallMeasurements = selectedIdSet.size === 1 && selectedWallEntries.length === 1 - ? getSelectedWallMeasurementOverlays(selectedWallEntries[0]!, wallPolygons, unit) + ? getSelectedWallMeasurementOverlays( + selectedWallEntries[0]!, + wallPolygons, + unit, + metersPerUnit, + ) : [] return ( @@ -3432,6 +3706,13 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ ? palette.selectedSlabStroke : palette.slabStroke const slabBorderWidth = showSelectedSlabStyle ? '1.2' : '1' + const slabFillOpacity = isDeleteHovered + ? 1 + : isGuideTraceVisible + ? showSelectedSlabStyle + ? FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY + : FLOORPLAN_TRACE_STRUCTURE_FILL_OPACITY + : 1 let slabLabel = null if (isSelected) { @@ -3455,7 +3736,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ x={toSvgX(centroid.x)} y={toSvgY(centroid.y)} > - {formatArea(area, unit)} + {formatArea(area, unit, metersPerUnit)} ) } @@ -3468,6 +3749,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ d={path} fill={palette.surface} fillRule="evenodd" + opacity={isGuideTraceVisible ? FLOORPLAN_TRACE_SURFACE_FILL_OPACITY : 1} pointerEvents="none" stroke="none" /> @@ -3476,6 +3758,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ d={path} fill={isDeleteHovered ? palette.deleteFill : palette.slabFill} fillRule="evenodd" + opacity={slabFillOpacity} onClick={ canSelectSlabs ? (event) => { @@ -3504,7 +3787,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ d={path} fill={`url(#${slabSelectionHatchId})`} fillRule="evenodd" - opacity={1} + opacity={isGuideTraceVisible ? FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY : 1} pointerEvents="none" /> ) : null} @@ -3536,6 +3819,13 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ ? palette.selectedCeilingStroke : palette.ceilingStroke const ceilingBorderWidth = showSelectedCeilingStyle ? '1.2' : '1' + const ceilingFillOpacity = isDeleteHovered + ? 1 + : isGuideTraceVisible + ? showSelectedCeilingStyle + ? FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY + : FLOORPLAN_TRACE_STRUCTURE_FILL_OPACITY + : 1 return ( @@ -3544,6 +3834,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ d={path} fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill} fillRule="evenodd" + opacity={ceilingFillOpacity} onClick={ canSelectCeilings ? (event) => { @@ -3574,7 +3865,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ d={path} fill={`url(#${slabSelectionHatchId})`} fillRule="evenodd" - opacity={1} + opacity={isGuideTraceVisible ? FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY : 1} pointerEvents="none" /> ) : null} @@ -3635,7 +3926,13 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ /> )} { @@ -5739,6 +6036,14 @@ export function FloorplanPanel() { const [zoneBoundaryDraft, setZoneBoundaryDraft] = useState(null) const [zoneVertexDragState, setZoneVertexDragState] = useState(null) const [guideTransformDraft, setGuideTransformDraft] = useState(null) + const [referenceScaleDraft, setReferenceScaleDraft] = useState(null) + const [pendingReferenceScale, setPendingReferenceScale] = useState( + null, + ) + const [referenceScaleValue, setReferenceScaleValue] = useState('1') + const [referenceScaleUnit, setReferenceScaleUnit] = useState( + unit === 'imperial' ? 'feet' : 'meters', + ) const [cursorPoint, setCursorPoint] = useState(null) const [floorplanCursorPosition, setFloorplanCursorPosition] = useState(null) const [wallEndpointDraft, setWallEndpointDraft] = useState(null) @@ -5887,14 +6192,33 @@ export function FloorplanPanel() { : guide, ) }, [guideTransformDraft, visibleGuides]) + const isGuideTraceVisible = displayGuides.some((guide) => guide.opacity > 0 && guide.scale > 0) const selectedGuideId = selectedReferenceId && guideById.has(selectedReferenceId as GuideNode['id']) ? (selectedReferenceId as GuideNode['id']) : null const selectedGuide = useMemo( - () => displayGuides.find((guide) => guide.id === selectedGuideId) ?? null, - [displayGuides, selectedGuideId], + () => + displayGuides.find((guide) => guide.id === selectedGuideId) ?? + (selectedGuideId ? (guideById.get(selectedGuideId) ?? null) : null), + [displayGuides, guideById, selectedGuideId], ) + const calibratedMeasurementGuide = useMemo(() => { + if ( + selectedGuide?.scaleReference && + selectedGuide.scaleReference.metersPerUnit > 0 && + selectedGuide.visible !== false + ) { + return selectedGuide + } + + return ( + visibleGuides.find( + (guide) => guide.scaleReference && guide.scaleReference.metersPerUnit > 0, + ) ?? null + ) + }, [selectedGuide, visibleGuides]) + const calibratedMetersPerUnit = calibratedMeasurementGuide?.scaleReference?.metersPerUnit ?? null const selectedGuideResolvedUrl = useResolvedAssetUrl(selectedGuide?.url ?? '') const selectedGuideDimensions = useGuideImageDimensions(selectedGuideResolvedUrl) const activeGuideInteractionGuideId = guideTransformDraft @@ -6599,7 +6923,7 @@ export function FloorplanPanel() { `${selectedItemEntry.item.id}:clearance:${index}`, midpoint, bestHit.point, - formatMeasurement(bestHit.distance, unit), + formatMeasurement(bestHit.distance, unit, calibratedMetersPerUnit), { extensionOvershoot: 0, }, @@ -6607,7 +6931,7 @@ export function FloorplanPanel() { return overlay ? [overlay] : [] }) - }, [displayWallPolygons, selectedItemEntry, unit]) + }, [calibratedMetersPerUnit, displayWallPolygons, selectedItemEntry, unit]) const movingOpeningPlacementMeasurements = useMemo(() => { if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) { return [] as LinearMeasurementOverlay[] @@ -6705,7 +7029,7 @@ export function FloorplanPanel() { `${opening.id}:placement-left`, leftBoundaryPoint, openingFaceStart, - formatMeasurement(leftDistance, unit), + formatMeasurement(leftDistance, unit, calibratedMetersPerUnit), { offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, offsetVector: faceContext.outwardNormal, @@ -6729,7 +7053,7 @@ export function FloorplanPanel() { `${opening.id}:placement-right`, openingFaceEnd, rightBoundaryPoint, - formatMeasurement(rightDistance, unit), + formatMeasurement(rightDistance, unit, calibratedMetersPerUnit), { offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, offsetVector: faceContext.outwardNormal, @@ -6747,7 +7071,7 @@ export function FloorplanPanel() { } return overlays - }, [displayWallPolygons, movingNode, openingsPolygons, unit]) + }, [calibratedMetersPerUnit, displayWallPolygons, movingNode, openingsPolygons, unit]) const selectedWallEntry = useMemo(() => { if (selectedIds.length !== 1) { return null @@ -6941,7 +7265,11 @@ export function FloorplanPanel() { structureLayer !== 'zones' const canInteractElementFloorplanGeometry = isDeleteMode || canSelectElementFloorplanGeometry const canInteractFloorplanSlabs = isDeleteMode || canSelectElementFloorplanGeometry - const canInteractWithGuides = showGuides && canSelectElementFloorplanGeometry + const canInteractWithGuides = + showGuides && + canSelectElementFloorplanGeometry && + !referenceScaleDraft && + !pendingReferenceScale const canSelectFloorplanZones = mode === 'select' && floorplanSelectionTool === 'click' && @@ -8061,6 +8389,152 @@ export function FloorplanPanel() { ) const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) + useEffect(() => { + setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') + }, [unit]) + + const startReferenceScaleForGuide = useCallback( + (guideId: GuideNode['id']) => { + const guide = guideById.get(guideId) + if (!guide) { + return + } + + setReferenceScaleDraft({ + guideId: guide.id, + start: null, + cursor: null, + }) + setPendingReferenceScale(null) + setMode('select') + setFloorplanSelectionTool('click') + setShowGuides(true) + setSelection({ selectedIds: [], zoneId: null }) + setSelectedReferenceId(guide.id) + }, + [ + guideById, + setFloorplanSelectionTool, + setMode, + setSelectedReferenceId, + setSelection, + setShowGuides, + ], + ) + + useEffect(() => { + const handleSetReferenceScale = (payload: { guideId?: GuideNode['id'] }) => { + if (payload.guideId) { + startReferenceScaleForGuide(payload.guideId) + } + } + + emitter.on('guide:set-reference-scale', handleSetReferenceScale) + return () => { + emitter.off('guide:set-reference-scale', handleSetReferenceScale) + } + }, [startReferenceScaleForGuide]) + + useEffect(() => { + const handleCancel = () => { + setReferenceScaleDraft(null) + setPendingReferenceScale(null) + } + + emitter.on('guide:cancel-reference-scale', handleCancel) + return () => { + emitter.off('guide:cancel-reference-scale', handleCancel) + } + }, []) + + useEffect(() => { + const handleDeleted = (payload: { guideId?: GuideNode['id'] }) => { + if (!payload.guideId) { + return + } + + setReferenceScaleDraft((current) => (current?.guideId === payload.guideId ? null : current)) + setPendingReferenceScale((current) => (current?.guideId === payload.guideId ? null : current)) + } + + emitter.on('guide:deleted', handleDeleted) + return () => { + emitter.off('guide:deleted', handleDeleted) + } + }, []) + + const handleReferenceScaleConfirm = useCallback(() => { + if (!pendingReferenceScale) { + return + } + + const guide = guideById.get(pendingReferenceScale.guideId) + if (!guide) { + setPendingReferenceScale(null) + return + } + + const displayLength = Number(referenceScaleValue) + if (!(displayLength > 0)) { + return + } + + const realLengthMeters = convertReferenceLengthToMeters(displayLength, referenceScaleUnit) + const requestedScaleFactor = realLengthMeters / pendingReferenceScale.measuredLengthUnits + const currentGuideScale = guide.scale > 0 ? guide.scale : 1 + const nextGuideScale = Math.max( + currentGuideScale * requestedScaleFactor, + FLOORPLAN_GUIDE_MIN_SCALE, + ) + const appliedScaleFactor = nextGuideScale / currentGuideScale + const scaledEnd: WallPlanPoint = [ + pendingReferenceScale.start[0] + + (pendingReferenceScale.end[0] - pendingReferenceScale.start[0]) * appliedScaleFactor, + pendingReferenceScale.start[1] + + (pendingReferenceScale.end[1] - pendingReferenceScale.start[1]) * appliedScaleFactor, + ] + const scaledMeasuredLengthUnits = Math.hypot( + scaledEnd[0] - pendingReferenceScale.start[0], + scaledEnd[1] - pendingReferenceScale.start[1], + ) + const nextGuidePosition: GuideNode['position'] = [ + pendingReferenceScale.start[0] + + (guide.position[0] - pendingReferenceScale.start[0]) * appliedScaleFactor, + guide.position[1], + pendingReferenceScale.start[1] + + (guide.position[2] - pendingReferenceScale.start[1]) * appliedScaleFactor, + ] + const metersPerUnit = + scaledMeasuredLengthUnits > 0 ? realLengthMeters / scaledMeasuredLengthUnits : 1 + + updateNode( + pendingReferenceScale.guideId as AnyNodeId, + { + locked: true, + position: nextGuidePosition, + scale: nextGuideScale, + scaleReference: { + start: pendingReferenceScale.start, + end: scaledEnd, + realLengthMeters, + measuredLengthUnits: scaledMeasuredLengthUnits, + metersPerUnit, + label: formatReferenceScaleLabel(displayLength, referenceScaleUnit), + visible: true, + }, + } as Partial, + ) + setSelectedReferenceId(pendingReferenceScale.guideId) + setPendingReferenceScale(null) + }, [ + guideById, + pendingReferenceScale, + referenceScaleUnit, + referenceScaleValue, + setSelectedReferenceId, + updateNode, + ]) + const getSvgPointFromClientPoint = useCallback( (clientX: number, clientY: number): SvgPoint | null => { const svg = svgRef.current @@ -8877,6 +9351,7 @@ export function FloorplanPanel() { number, ], scale: nextDraft.scale, + scaleReference: transformGuideScaleReference(guide, nextDraft), }) } @@ -10008,6 +10483,23 @@ export function FloorplanPanel() { return } + if (referenceScaleDraft) { + emitFloorplanGridEvent('move', planPoint, event) + + setCursorPoint((previousPoint) => + previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint, + ) + setReferenceScaleDraft((currentDraft) => + currentDraft + ? { + ...currentDraft, + cursor: planPoint, + } + : currentDraft, + ) + return + } + if (isCeilingBuildActive) { emitFloorplanGridEvent('move', planPoint, event) @@ -10181,6 +10673,7 @@ export function FloorplanPanel() { isPolygonBuildActive, isRoofBuildActive, isWallBuildActive, + referenceScaleDraft, roofDraftStart, ceilingHoleMoveDraft, ceilingHoleVertexDragState, @@ -10415,6 +10908,44 @@ export function FloorplanPanel() { return } + if (referenceScaleDraft) { + event.preventDefault() + event.stopPropagation() + + emitFloorplanGridEvent('click', planPoint, event) + + if (!referenceScaleDraft.start) { + setReferenceScaleDraft({ + ...referenceScaleDraft, + start: planPoint, + cursor: planPoint, + }) + setCursorPoint(planPoint) + return + } + + const measuredLengthUnits = Math.hypot( + planPoint[0] - referenceScaleDraft.start[0], + planPoint[1] - referenceScaleDraft.start[1], + ) + + if (measuredLengthUnits < 1e-6) { + return + } + + setPendingReferenceScale({ + guideId: referenceScaleDraft.guideId, + start: referenceScaleDraft.start, + end: planPoint, + measuredLengthUnits, + }) + setReferenceScaleValue(formatNumber(measuredLengthUnits, 2)) + setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') + setReferenceScaleDraft(null) + setCursorPoint(null) + return + } + if (handleBackgroundPlacementClick(planPoint, event, draftStart)) { return } @@ -10490,12 +11021,14 @@ export function FloorplanPanel() { isWallBuildActive, levelId, levelNode, + referenceScaleDraft, setSelectedReferenceId, setSelection, structureLayer, getFloorplanHitIdAtPoint, - toPoint2D, + unit, visibleZonePolygons, + emitFloorplanGridEvent, ], ) const handleBackgroundDoubleClick = useCallback( @@ -10896,7 +11429,7 @@ export function FloorplanPanel() { corner: GuideCorner, event: ReactPointerEvent, ) => { - if (event.button !== 0 || !canInteractWithGuides) { + if (event.button !== 0 || !canInteractWithGuides || guide.locked === true) { return } @@ -10912,7 +11445,7 @@ export function FloorplanPanel() { handleGuideSelect(guide.id) const centerSvg = getGuideCenterSvgPoint(guide) - const rotationSvg = -guide.rotation[1] + const rotationSvg = getGuideSvgRotation(guide.rotation[1]) const width = getGuideWidth(guide.scale) const height = getGuideHeight(width, aspectRatio) const [cornerOffsetX, cornerOffsetY] = getGuideCornerLocalOffset(width, height, corner) @@ -10959,7 +11492,12 @@ export function FloorplanPanel() { ) const handleGuideTranslateStart = useCallback( (guide: GuideNode, event: ReactPointerEvent) => { - if (event.button !== 0 || !canInteractWithGuides || selectedGuideId !== guide.id) { + if ( + event.button !== 0 || + !canInteractWithGuides || + selectedGuideId !== guide.id || + guide.locked === true + ) { return } @@ -10982,7 +11520,7 @@ export function FloorplanPanel() { centerSvg, oppositeCornerSvg: null, pointerOffsetSvg: subtractSvgPoints(svgPoint, centerSvg), - rotationSvg: -guide.rotation[1], + rotationSvg: getGuideSvgRotation(guide.rotation[1]), cornerBaseAngle: 0, scale: guide.scale, } @@ -13057,7 +13595,12 @@ export function FloorplanPanel() { selectedStairEntry, ]) const activeDraftAnchorPoint = - draftStart ?? fenceDraftStart ?? roofDraftStart ?? activePolygonDraftPoints[0] ?? null + referenceScaleDraft?.start ?? + draftStart ?? + fenceDraftStart ?? + roofDraftStart ?? + activePolygonDraftPoints[0] ?? + null const floorplanCursorColor = mode === 'delete' ? palette.deleteStroke @@ -13066,6 +13609,25 @@ export function FloorplanPanel() { : activeDraftAnchorPoint ? palette.draftStroke : palette.cursor + const pendingReferenceDisplayLength = Number(referenceScaleValue) + const pendingReferenceRealLengthMeters = + pendingReferenceScale && pendingReferenceDisplayLength > 0 + ? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit) + : null + const pendingReferenceMetersPerUnit = + pendingReferenceScale && pendingReferenceRealLengthMeters + ? pendingReferenceRealLengthMeters / pendingReferenceScale.measuredLengthUnits + : null + const pendingReferenceImageScaleFactor = + pendingReferenceScale && pendingReferenceRealLengthMeters + ? pendingReferenceRealLengthMeters / pendingReferenceScale.measuredLengthUnits + : null + const referenceScaleInputError = + referenceScaleValue.trim() === '' + ? 'Enter the real length of the line.' + : pendingReferenceDisplayLength > 0 + ? null + : 'Length must be greater than 0.' return (
+ {referenceScaleDraft && ( +
+ {referenceScaleDraft.start + ? 'Click the end of the known distance' + : 'Click the start of a known distance'} +
+ )} + + {pendingReferenceScale && ( +
{ + event.preventDefault() + handleReferenceScaleConfirm() + }} + > +
+
+ +
+
+
Set overlay scale
+
+ Enter the real-world length of the line you just drew. The image will resize to + match it. +
+
+
+ +
+
+ Drawn line +
+
+ {formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)} +
+
+ + + +
+ {pendingReferenceImageScaleFactor + ? `Image will scale ${formatNumber(pendingReferenceImageScaleFactor, 3)}x from the first point.` + : 'Enter a length greater than 0.'} +
+ +
+ + +
+
+ )} + {!levelNode || levelNode.type !== 'level' ? (
Switch to a building level to view and edit the floorplan. @@ -13173,7 +13845,7 @@ export function FloorplanPanel() { onPointerMove={handleSvgPointerMove} onPointerUp={endPanning} ref={svgRef} - style={{ cursor: EDITOR_CURSOR }} + style={{ cursor: referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR }} viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`} > @@ -13272,6 +13944,8 @@ export function FloorplanPanel() { slabSelectionHatchId={slabSelectionHatchId} slabPolygons={displaySlabPolygons} unit={unit} + metersPerUnit={calibratedMetersPerUnit} + isGuideTraceVisible={isGuideTraceVisible} wallPolygons={displayWallPolygons} wallSelectionHatchId={wallSelectionHatchId} /> @@ -13338,6 +14012,14 @@ export function FloorplanPanel() { selectedIdSet={selectedIdSet} /> + + ({ - x: toSvgX(point[0]), - y: toSvgY(point[1]), - isPrimary: index === 0, - }))} + draftAnchorPoints={[ + ...(referenceScaleDraft?.start + ? [ + { + x: toSvgX(referenceScaleDraft.start[0]), + y: toSvgY(referenceScaleDraft.start[1]), + isPrimary: true, + }, + ] + : []), + ...activePolygonDraftPoints.map((point, index) => ({ + x: toSvgX(point[0]), + y: toSvgY(point[1]), + isPrimary: index === 0, + })), + ]} draftFill={palette.draftFill} draftPolygonPoints={draftPolygonPoints} draftStroke={palette.draftStroke} @@ -13573,7 +14266,7 @@ export function FloorplanPanel() { onCornerHoverChange={setHoveredGuideCorner} onCornerPointerDown={handleGuideCornerPointerDown} rotationModifierPressed={rotationModifierPressed} - showHandles={canInteractWithGuides} + showHandles={canInteractWithGuides && selectedGuide.locked !== true} /> )} diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index c40e4024..cb83aafb 100755 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -11,6 +11,7 @@ import { useViewer } from '@pascal-app/viewer' import { Check, ChevronDown, Plus, Trash2 } from 'lucide-react' import { useCallback, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' +import { createLocalGuideImage } from '../../../lib/local-guide-image' import { cn } from '../../../lib/utils' import useEditor, { type GridSnapStep } from '../../../store/use-editor' import { useUploadStore } from '../../../store/use-upload' @@ -61,35 +62,67 @@ function useLevelScans(): ScanNode[] { // ── Shared upload button for dropdowns ────────────────────────────────────── -function UploadButton() { +function UploadButton({ onError }: { onError: (message: string | null) => void }) { const fileInputRef = useRef(null) const levelId = useViewer((s) => s.selection.levelId) + const setSelection = useViewer((s) => s.setSelection) + const setShowGuides = useViewer((s) => s.setShowGuides) + const createNode = useScene((s) => s.createNode) + const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) + const [isAddingGuide, setIsAddingGuide] = useState(false) const handleFileChange = useCallback( - (e: React.ChangeEvent) => { + async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!(file && levelId)) return e.target.value = '' - const { uploadHandler } = useUploadStore.getState() - if (!uploadHandler) return + onError(null) - if (file.size > MAX_FILE_SIZE) return + if (file.size > MAX_FILE_SIZE) { + onError('File is too large. Maximum size is 200 MB.') + return + } const isScan = file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') const isImage = file.type.startsWith('image/') - if (!(isScan || isImage)) return + if (!(isScan || isImage)) { + onError('Upload a .glb/.gltf scan or an image.') + return + } + + if (isImage) { + setIsAddingGuide(true) + try { + const guide = await createLocalGuideImage({ createNode, file, levelId }) + setShowGuides(true) + setSelectedReferenceId(guide.id) + setSelection({ selectedIds: [], zoneId: null }) + } catch { + onError('Could not add that guide image.') + } finally { + setIsAddingGuide(false) + } + return + } - const type = isScan ? 'scan' : 'guide' + const { uploadHandler } = useUploadStore.getState() + if (!uploadHandler) { + onError('Scan upload is unavailable.') + return + } const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0] - if (!projectId) return + if (!projectId) { + onError('Open a project before uploading a scan.') + return + } useUploadStore.getState().clearUpload(levelId) - uploadHandler(projectId, levelId, file, type) + uploadHandler(projectId, levelId, file, 'scan') }, - [levelId], + [createNode, levelId, onError, setSelectedReferenceId, setSelection, setShowGuides], ) return ( @@ -97,6 +130,7 @@ function UploadButton() {
+ {uploadError && ( +
+ {uploadError} +
+ )} + {hasGuides ? (
{guides.map((guide, index) => (
- -

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

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

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

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

Reference floor

+ {selectedLevel && ( +

{selectedLevel.name}

+ )} +
+ +
+ + {hasLowerLevels ? ( + <> +
+ {lowerLevels.map((level, index) => { + const isSelected = referenceFloorOffset === index + 1 + return ( + + ) + })} +
+ + + + ) : ( +
+ No lower floor available. +
+ )} +
+
+ + ) +} + // ── Main ViewToggles ──────────────────────────────────────────────────────── export function ViewToggles() { @@ -588,6 +761,8 @@ export function ViewToggles() { {/* Guides (toggle + dropdown) */} + +
) } @@ -599,6 +774,7 @@ export function SecondaryToggles() { +
) } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index d25b33a5..cd3ed6f8 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -96,7 +96,11 @@ export type MovingFenceEndpoint = { endpoint: 'start' | 'end' } -export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole +export type MaterialTargetRole = + | WallSurfaceSide + | StairSurfaceMaterialRole + | RoofSurfaceMaterialRole + | SingleSurfaceMaterialRole export type SelectedMaterialTarget = { nodeId: AnyNodeId @@ -197,6 +201,13 @@ type EditorState = { setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void gridSnapStep: GridSnapStep setGridSnapStep: (step: GridSnapStep) => void + showReferenceFloor: boolean + toggleReferenceFloor: () => void + setShowReferenceFloor: (show: boolean) => void + referenceFloorOffset: number + setReferenceFloorOffset: (offset: number) => void + referenceFloorOpacity: number + setReferenceFloorOpacity: (opacity: number) => void // First-person walkthrough mode (street view) isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null @@ -226,6 +237,9 @@ type PersistedEditorLayoutState = Pick< | 'splitOrientation' | 'floorplanSelectionTool' | 'gridSnapStep' + | 'showReferenceFloor' + | 'referenceFloorOffset' + | 'referenceFloorOpacity' > type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState @@ -245,6 +259,9 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = splitOrientation: 'horizontal', floorplanSelectionTool: 'click', gridSnapStep: 0.5, + showReferenceFloor: false, + referenceFloorOffset: 1, + referenceFloorOpacity: 0.35, } const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] @@ -354,6 +371,16 @@ function normalizePersistedEditorLayoutState( gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep) ? (state?.gridSnapStep as GridSnapStep) : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, + showReferenceFloor: state?.showReferenceFloor === true, + referenceFloorOffset: + typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 + ? Math.floor(state.referenceFloorOffset) + : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset, + referenceFloorOpacity: + typeof state?.referenceFloorOpacity === 'number' && + Number.isFinite(state.referenceFloorOpacity) + ? Math.min(0.8, Math.max(0.1, state.referenceFloorOpacity)) + : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity, } } @@ -632,6 +659,16 @@ const useEditor = create()( setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, setGridSnapStep: (step) => set({ gridSnapStep: step }), + showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, + toggleReferenceFloor: () => + set((state) => ({ showReferenceFloor: !state.showReferenceFloor })), + setShowReferenceFloor: (show) => set({ showReferenceFloor: show }), + referenceFloorOffset: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset, + setReferenceFloorOffset: (offset) => + set({ referenceFloorOffset: Math.max(1, Math.floor(offset)) }), + referenceFloorOpacity: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity, + setReferenceFloorOpacity: (opacity) => + set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }), allowUndergroundCamera: false, setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), isFirstPersonMode: false, @@ -702,6 +739,9 @@ const useEditor = create()( splitOrientation: state.splitOrientation, floorplanSelectionTool: state.floorplanSelectionTool, gridSnapStep: state.gridSnapStep, + showReferenceFloor: state.showReferenceFloor, + referenceFloorOffset: state.referenceFloorOffset, + referenceFloorOpacity: state.referenceFloorOpacity, }), }, ), From bbc638af22621aff0dfc2395585ce034bb0b6d1b Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Apr 2026 19:19:57 +0530 Subject: [PATCH 06/13] Handle unnamed reference floor levels --- .../components/ui/action-menu/view-toggles.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index c092d69d..c0fa2c94 100755 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -83,6 +83,10 @@ function useLowerReferenceLevels(): LevelNode[] { ) } +function getLevelDisplayName(level: LevelNode) { + return level.name || `Level ${level.level}` +} + // ── Shared upload button for dropdowns ────────────────────────────────────── function UploadButton({ onError }: { onError: (message: string | null) => void }) { @@ -612,6 +616,7 @@ function ReferenceFloorControl() { const lowerLevels = useLowerReferenceLevels() const hasLowerLevels = lowerLevels.length > 0 const selectedLevel = lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null + const selectedLevelName = selectedLevel ? getLevelDisplayName(selectedLevel) : null return ( @@ -625,8 +630,8 @@ function ReferenceFloorControl() { )} disabled={!hasLowerLevels} label={ - selectedLevel && showReferenceFloor - ? `Reference floor: ${selectedLevel.name}` + selectedLevelName && showReferenceFloor + ? `Reference floor: ${selectedLevelName}` : 'Reference floor' } onClick={() => { @@ -678,8 +683,8 @@ function ReferenceFloorControl() {

Reference floor

- {selectedLevel && ( -

{selectedLevel.name}

+ {selectedLevelName && ( +

{selectedLevelName}

)}
+ +
+ + handleUpdate( + v === 'opening' + ? { + openingKind: v, + openingShape, + cornerRadius, + archHeight, + openingRevealRadius, + } + : { openingKind: v }, + ) + } + options={[ + { label: 'Door', value: 'door' }, + { label: 'Opening', value: 'opening' }, + ]} + value={node.openingKind} + /> +
+
+ -
- } - label="Flip Side" - onClick={handleFlip} - /> -
+ {!isOpening && ( +
+ } + label="Flip Side" + onClick={handleFlip} + /> +
+ )}
@@ -256,6 +294,66 @@ export function DoorPanel() { /> + {isOpening && ( + +
+ + handleUpdate({ + openingShape: v, + ...(v === 'rounded' ? { cornerRadius, openingRevealRadius } : {}), + ...(v === 'arch' ? { archHeight } : {}), + }) + } + options={[ + { label: 'Rect', value: 'rectangle' }, + { label: 'Rounded', value: 'rounded' }, + { label: 'Arch', value: 'arch' }, + ]} + value={openingShape} + /> +
+ {openingShape === 'rounded' && ( + <> + handleUpdate({ cornerRadius: v })} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + handleUpdate({ openingRevealRadius: v })} + precision={3} + step={0.005} + unit="m" + value={Math.round(openingRevealRadius * 1000) / 1000} + /> + + )} + {openingShape === 'arch' && ( + handleUpdate({ archHeight: v })} + precision={2} + step={0.05} + unit="m" + value={Math.round(archHeight * 100) / 100} + /> + )} +
+ )} + + {!isOpening && ( + <> + + )} + } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index a87d40ce..dd762386 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -153,12 +153,14 @@ export const useKeyboard = ({ const node = useScene.getState().nodes[selectedNodeIds[0]!] if (node?.type === 'door') { e.preventDefault() - const currentSwingAngle = node.swingAngle ?? 0 - useScene.getState().updateNode(node.id, { - swingAngle: - currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, - }) - sfxEmitter.emit('sfx:item-rotate') + if (node.openingKind !== 'opening') { + const currentSwingAngle = node.swingAngle ?? 0 + useScene.getState().updateNode(node.id, { + swingAngle: + currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + }) + sfxEmitter.emit('sfx:item-rotate') + } } else if (node && 'rotation' in node) { e.preventDefault() const ROTATION_STEP = Math.PI / 4 @@ -181,8 +183,10 @@ export const useKeyboard = ({ const node = useScene.getState().nodes[selectedNodeIds[0]!] if (node?.type === 'door') { e.preventDefault() - useScene.getState().updateNode(node.id, { swingAngle: 0 }) - sfxEmitter.emit('sfx:item-rotate') + if (node.openingKind !== 'opening') { + useScene.getState().updateNode(node.id, { swingAngle: 0 }) + sfxEmitter.emit('sfx:item-rotate') + } } else if (node && 'rotation' in node) { e.preventDefault() const ROTATION_STEP = Math.PI / 4 diff --git a/packages/viewer/src/components/renderers/door/door-renderer.tsx b/packages/viewer/src/components/renderers/door/door-renderer.tsx index 6c978f3e..ff389f31 100644 --- a/packages/viewer/src/components/renderers/door/door-renderer.tsx +++ b/packages/viewer/src/components/renderers/door/door-renderer.tsx @@ -1,8 +1,9 @@ import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' -import { useLayoutEffect, useMemo, useRef } from 'react' -import type { Mesh } from 'three' +import { useLayoutEffect, useRef } from 'react' +import { MeshBasicMaterial, type Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, DEFAULT_DOOR_MATERIAL } from '../../../lib/materials' + +const doorHitboxMaterial = new MeshBasicMaterial({ visible: false }) export const DoorRenderer = ({ node }: { node: DoorNode }) => { const ref = useRef(null!) @@ -14,16 +15,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { const handlers = useNodeEvents(node, 'door') const isTransient = !!(node.metadata as Record | null)?.isTransient - const material = useMemo(() => { - const mat = node.material - if (!mat) return DEFAULT_DOOR_MATERIAL - return createMaterial(mat) - }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) - return ( Date: Fri, 1 May 2026 13:54:23 +0530 Subject: [PATCH 11/13] Refactor editor selection and viewer integration --- packages/core/src/schema/nodes/door.ts | 2 + packages/core/src/schema/nodes/window.ts | 11 + .../core/src/systems/wall/wall-system.tsx | 168 +++++- .../core/src/systems/window/window-system.tsx | 10 + .../editor/first-person-controls.tsx | 181 +++++- .../first-person/build-collider-world.ts | 87 ++- .../src/components/editor/floorplan-panel.tsx | 306 +++++++++- .../components/ui/controls/slider-control.tsx | 11 +- .../src/components/ui/panels/door-panel.tsx | 152 ++++- .../src/components/ui/panels/window-panel.tsx | 553 +++++++++++++----- 10 files changed, 1237 insertions(+), 244 deletions(-) diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 57e0ad46..8940270a 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -35,6 +35,8 @@ export const DoorNode = BaseNode.extend({ // Opening mode openingKind: z.enum(['door', 'opening']).default('door'), openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), + openingRadiusMode: z.enum(['all', 'individual']).default('all'), + openingTopRadii: z.tuple([z.number(), z.number()]).default([0.15, 0.15]), cornerRadius: z.number().min(0).default(0.15), archHeight: z.number().min(0).default(0.45), openingRevealRadius: z.number().min(0).default(0.025), diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 7e28b71f..a497175a 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -19,6 +19,17 @@ export const WindowNode = BaseNode.extend({ width: z.number().default(1.5), height: z.number().default(1.5), + // Opening mode - when set to "opening", the window is only a shaped cutout + openingKind: z.enum(['window', 'opening']).default('window'), + openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), + openingRadiusMode: z.enum(['all', 'individual']).default('all'), + openingCornerRadii: z + .tuple([z.number(), z.number(), z.number(), z.number()]) + .default([0.15, 0.15, 0.15, 0.15]), + cornerRadius: z.number().default(0.15), + archHeight: z.number().default(0.35), + openingRevealRadius: z.number().default(0.025), + // Frame frameThickness: z.number().default(0.05), frameDepth: z.number().default(0.07), diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index ba47cea0..dc2bf8a2 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -5,7 +5,7 @@ import { computeBoundsTree } from 'three-mesh-bvh' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import type { AnyNode, AnyNodeId, DoorNode, WallNode } from '../../schema' +import type { AnyNode, AnyNodeId, DoorNode, WallNode, WindowNode } from '../../schema' import useScene from '../../store/use-scene' import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' @@ -546,8 +546,11 @@ function collectCutoutBrushes( for (const child of childrenNodes) { if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue - if (child.type === 'door' && child.openingKind === 'opening') { - brushes.push(createDoorOpeningCutoutBrush(child, wallThickness)) + if ( + (child.type === 'door' && child.openingKind === 'opening') || + (child.type === 'window' && child.openingKind === 'opening') + ) { + brushes.push(createShapedOpeningCutoutBrush(child, wallThickness)) continue } @@ -606,15 +609,23 @@ function collectCutoutBrushes( return brushes } -function createDoorOpeningCutoutBrush(door: DoorNode, wallThickness: number): Brush { - const shape = createDoorOpeningCutoutShape(door) +type ShapedOpeningNode = DoorNode | WindowNode +type CornerRadii = { + topLeft: number + topRight: number + bottomRight: number + bottomLeft: number +} + +function createShapedOpeningCutoutBrush(opening: ShapedOpeningNode, wallThickness: number): Brush { + const shape = createShapedOpeningCutoutShape(opening) const depth = wallThickness * 2 const bevelSize = - door.openingShape === 'rounded' + opening.openingShape === 'rounded' ? Math.min( - Math.max(door.openingRevealRadius ?? 0.025, 0), + Math.max(opening.openingRevealRadius ?? 0.025, 0), Math.max(wallThickness * 0.45, 0.001), - Math.max((door.cornerRadius ?? 0.15) * 0.45, 0.001), + Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001), ) : 0 const geometry = new THREE.ExtrudeGeometry(shape, { @@ -633,19 +644,19 @@ function createDoorOpeningCutoutBrush(door: DoorNode, wallThickness: number): Br return new Brush(geometry) } -function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape { - const halfWidth = door.width / 2 - const bottom = door.position[1] - door.height / 2 - const top = door.position[1] + door.height / 2 - const centerX = door.position[0] +function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape { + const halfWidth = opening.width / 2 + const bottom = opening.position[1] - opening.height / 2 + const top = opening.position[1] + opening.height / 2 + const centerX = opening.position[0] const left = centerX - halfWidth const right = centerX + halfWidth - const width = Math.max(door.width, 1e-6) - const height = Math.max(door.height, 1e-6) + const width = Math.max(opening.width, 1e-6) + const height = Math.max(opening.height, 1e-6) const shape = new THREE.Shape() - if (door.openingShape === 'arch') { - const archHeight = Math.min(Math.max(door.archHeight ?? width / 2, 0.01), height) + if (opening.openingShape === 'arch') { + const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height) const springY = top - archHeight shape.moveTo(left, bottom) @@ -657,17 +668,9 @@ function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape { return shape } - if (door.openingShape === 'rounded') { - const radius = Math.min(Math.max(door.cornerRadius ?? 0.15, 0), width / 2, height) - - shape.moveTo(left, bottom) - shape.lineTo(right, bottom) - shape.lineTo(right, top - radius) - shape.absarc(right - radius, top - radius, radius, 0, Math.PI / 2, false) - shape.lineTo(left + radius, top) - shape.absarc(left + radius, top - radius, radius, Math.PI / 2, Math.PI, false) - shape.lineTo(left, bottom) - shape.closePath() + if (opening.openingShape === 'rounded') { + const radii = getRoundedOpeningRadii(opening, width, height) + applyRoundedOpeningShape(shape, left, right, bottom, top, radii) return shape } @@ -678,3 +681,112 @@ function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape { shape.closePath() return shape } + +function getRoundedOpeningRadii( + opening: ShapedOpeningNode, + width: number, + height: number, +): CornerRadii { + if (opening.type !== 'window') { + if (opening.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15] + + return normalizeCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + bottomRight: 0, + bottomLeft: 0, + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height) + const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 } + } + + if (opening.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = + opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] + + return normalizeCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + bottomRight: Math.max(bottomRight, 0), + bottomLeft: Math.max(bottomLeft, 0), + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height / 2) + const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius } +} + +function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii { + const next = { ...radii } + const maxScale = Math.min( + 1, + width / Math.max(next.topLeft + next.topRight, 1e-6), + width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), + height / Math.max(next.topLeft + next.bottomLeft, 1e-6), + height / Math.max(next.topRight + next.bottomRight, 1e-6), + ) + + if (maxScale < 1) { + next.topLeft *= maxScale + next.topRight *= maxScale + next.bottomRight *= maxScale + next.bottomLeft *= maxScale + } + + return next +} + +function applyRoundedOpeningShape( + shape: THREE.Shape, + left: number, + right: number, + bottom: number, + top: number, + radii: CornerRadii, +) { + const { topLeft, topRight, bottomRight, bottomLeft } = radii + + shape.moveTo(left + bottomLeft, bottom) + shape.lineTo(right - bottomRight, bottom) + if (bottomRight > 1e-6) { + shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false) + } else { + shape.lineTo(right, bottom) + } + + shape.lineTo(right, top - topRight) + if (topRight > 1e-6) { + shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false) + } else { + shape.lineTo(right, top) + } + + shape.lineTo(left + topLeft, top) + if (topLeft > 1e-6) { + shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false) + } else { + shape.lineTo(left, top) + } + + shape.lineTo(left, bottom + bottomLeft) + if (bottomLeft > 1e-6) { + shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false) + } else { + shape.lineTo(left, bottom) + } + + shape.closePath() +} diff --git a/packages/core/src/systems/window/window-system.tsx b/packages/core/src/systems/window/window-system.tsx index d27e2857..65d68455 100644 --- a/packages/core/src/systems/window/window-system.tsx +++ b/packages/core/src/systems/window/window-system.tsx @@ -81,8 +81,14 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { sill, sillDepth, sillThickness, + openingKind, } = node + if (openingKind === 'opening') { + syncWindowCutout(node, mesh) + return + } + const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness @@ -231,6 +237,10 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { ) } + syncWindowCutout(node, mesh) +} + +function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ── let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined if (!cutout) { diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index b6365ce9..ee39342e 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,15 +1,13 @@ 'use client' import '../../three-types' -import { KeyboardControls } from '@react-three/drei' -import { sceneRegistry, useScene } from '@pascal-app/core' +import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Euler, Vector3 } from 'three' +import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three' import useEditor from '../../store/use-editor' -import BVHEcctrl from './first-person/bvh-ecctrl' -import type { BVHEcctrlApi } from './first-person/bvh-ecctrl' import { buildFirstPersonColliderWorldFromRegistry, deriveFirstPersonSpawn, @@ -17,10 +15,15 @@ import { type FirstPersonColliderWorld, type FirstPersonSpawn, } from './first-person/build-collider-world' +import type { BVHEcctrlApi } from './first-person/bvh-ecctrl' +import BVHEcctrl from './first-person/bvh-ecctrl' const CAMERA_EYE_OFFSET = 0.45 const LOOK_SENSITIVITY = 0.002 const CONTROLLER_CENTER_FROM_EYE = 0.85 +const DOOR_INTERACTION_DISTANCE = 2.5 +const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 +const DOOR_LEAF_INTERACTION_DEPTH = 0.08 const keyboardMap = [ { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, @@ -32,13 +35,21 @@ const keyboardMap = [ const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) const cameraEuler = new Euler(0, 0, 0, 'YXZ') +const centerScreenPoint = new Vector2(0, 0) +const doorInteractionRaycaster = new Raycaster() +const doorLeafBox = new Box3() +const doorLeafInverseMatrix = new Matrix4() +const doorLeafLocalHit = new Vector3() +const doorLeafLocalRay = new Ray() +const doorLeafMatrix = new Matrix4() +const doorLeafWorldHit = new Vector3() const spawnWorldPosition = new Vector3() const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const resolvePlacedSpawnNode = ( nodes: ReturnType['nodes'], _levelId: string | null, - ) => { +) => { const candidates = Object.values(nodes).filter((node) => node.type === 'spawn') if (candidates.length === 0) return null @@ -52,7 +63,101 @@ export const FirstPersonControls = () => { const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) + const interactableDoorIdRef = useRef(null) + const worldRef = useRef(null) const [world, setWorld] = useState(null) + const [controllerStart, setControllerStart] = useState<{ + position: [number, number, number] + yaw: number + } | null>(null) + + const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => { + worldRef.current?.dispose() + worldRef.current = nextWorld + setWorld(nextWorld) + }, []) + + const rebuildColliderWorld = useCallback(() => { + replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry()) + }, [replaceColliderWorld]) + + const resolveInteractableDoorId = useCallback((): AnyNodeId | null => { + const nodes = useScene.getState().nodes + camera.updateMatrixWorld(true) + doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera) + + let closestDoorId: AnyNodeId | null = null + let closestDistance = DOOR_INTERACTION_DISTANCE + + for (const doorId of sceneRegistry.byType.door) { + const node = nodes[doorId as AnyNodeId] + if (node?.type !== 'door') continue + if (node.openingKind === 'opening') continue + if (node.segments.every((segment) => segment.type === 'empty')) continue + + const object = sceneRegistry.nodes.get(doorId) + if (!object) continue + + object.updateWorldMatrix(true, true) + + const placementHit = doorInteractionRaycaster + .intersectObject(object, true) + .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE) + if (placementHit && placementHit.distance < closestDistance) { + closestDoorId = doorId as AnyNodeId + closestDistance = placementHit.distance + } + + const leafW = node.width - 2 * node.frameThickness + const leafH = node.height - node.frameThickness + if (leafW <= 0 || leafH <= 0) continue + + const leafCenterY = -node.frameThickness / 2 + const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 + const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 + const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 + const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, node.swingAngle ?? 0)) + const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign + + doorLeafMatrix + .copy(object.matrixWorld) + .multiply(new Matrix4().makeTranslation(hingeX, 0, 0)) + .multiply(new Matrix4().makeRotationY(leafSwingRotation)) + .multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0)) + doorLeafInverseMatrix.copy(doorLeafMatrix).invert() + doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2) + doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2) + doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix) + + const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit) + if (!localHit) continue + + doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix) + const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin) + + if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) { + closestDoorId = doorId as AnyNodeId + closestDistance = hitDistance + } + } + + return closestDoorId + }, [camera]) + + const toggleInteractableDoor = useCallback(() => { + const doorId = interactableDoorIdRef.current ?? resolveInteractableDoorId() + if (!doorId) return + + const node = useScene.getState().nodes[doorId] + if (node?.type !== 'door' || node.openingKind === 'opening') return + + const currentSwingAngle = node.swingAngle ?? 0 + useScene.getState().updateNode(doorId, { + swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + }) + + requestAnimationFrame(rebuildColliderWorld) + }, [rebuildColliderWorld, resolveInteractableDoorId]) const placedSpawn = useMemo(() => { if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null @@ -84,25 +189,28 @@ export const FirstPersonControls = () => { }, [placedSpawnNode]) useEffect(() => { - const nextWorld = buildFirstPersonColliderWorldFromRegistry() - if (!nextWorld) { - setWorld(null) - return - } - - setWorld(nextWorld) + rebuildColliderWorld() return () => { - nextWorld.dispose() + worldRef.current?.dispose() + worldRef.current = null setWorld(null) } - }, [camera]) + }, [rebuildColliderWorld]) useEffect(() => { if (!world) return - yawRef.current = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw + if (controllerStart) return + + const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world) + const [x, y, z] = spawn.position + yawRef.current = spawn.yaw pitchRef.current = 0 - }, [camera, placedSpawn, world]) + setControllerStart({ + position: [x, y - CONTROLLER_CENTER_FROM_EYE, z], + yaw: spawn.yaw, + }) + }, [camera, controllerStart, placedSpawn, world]) useEffect(() => { const canvas = gl.domElement @@ -152,6 +260,10 @@ export const FirstPersonControls = () => { document.exitPointerLock() } useEditor.getState().setFirstPersonMode(false) + } else if (event.code === 'KeyE') { + event.preventDefault() + event.stopPropagation() + toggleInteractableDoor() } } @@ -159,7 +271,7 @@ export const FirstPersonControls = () => { return () => { document.removeEventListener('keydown', handleKeyDown, true) } - }, [gl]) + }, [gl, toggleInteractableDoor]) useFrame((_, delta) => { if (!controllerRef.current?.group) return @@ -170,18 +282,21 @@ export const FirstPersonControls = () => { cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') camera.quaternion.setFromEuler(cameraEuler) camera.updateMatrixWorld(true) - }) - const controllerPosition = useMemo(() => { - if (!world) return null - const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position - return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const - }, [camera, placedSpawn, world]) + const nextInteractableDoorId = resolveInteractableDoorId() + if (interactableDoorIdRef.current !== nextInteractableDoorId) { + interactableDoorIdRef.current = nextInteractableDoorId + useViewer.getState().setHoveredId(nextInteractableDoorId) + } + }) - const spawnYaw = useMemo(() => { - if (!world) return 0 - return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw - }, [camera, placedSpawn, world]) + useEffect(() => { + return () => { + if (useViewer.getState().hoveredId === interactableDoorIdRef.current) { + useViewer.getState().setHoveredId(null) + } + } + }, []) if (!world) { return null @@ -189,11 +304,11 @@ export const FirstPersonControls = () => { return ( <> - {controllerPosition && ( + {controllerStart && ( { maxRunSpeed={5.5} maxSlope={1.2} maxWalkSpeed={4} - position={controllerPosition} + position={controllerStart.position} acceleration={26} airDragFactor={0.3} deceleration={30} @@ -293,7 +408,9 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
- Click to look around + + Click to look around +
)} diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 0b951ede..c373d6da 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -1,11 +1,7 @@ -import { sceneRegistry, useScene } from '@pascal-app/core' -import { - acceleratedRaycast, - computeBoundsTree, - disposeBoundsTree, -} from 'three-mesh-bvh' -import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core' import * as THREE from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' const COLLIDER_NODE_TYPES = [ 'wall', @@ -16,6 +12,7 @@ const COLLIDER_NODE_TYPES = [ 'roof', 'roof-segment', 'door', + 'window', 'item', ] as const @@ -25,6 +22,7 @@ const DOWN = new THREE.Vector3(0, -1, 0) const UP = new THREE.Vector3(0, 1, 0) const SPAWN_EYE_HEIGHT = 1.65 const RAYCAST_CLEARANCE = 25 +const DOOR_LEAF_COLLIDER_DEPTH = 0.06 export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT @@ -46,9 +44,7 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh { } function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) { - return Array.isArray(material) - ? material.some((entry) => entry.visible) - : material.visible + return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible } function cloneWorldGeometry(mesh: THREE.Mesh) { @@ -56,7 +52,9 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { const position = sourceGeometry.getAttribute('position') if (!position || position.count < 3) return null - const workingGeometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() + const workingGeometry = sourceGeometry.index + ? sourceGeometry.toNonIndexed() + : sourceGeometry.clone() const cleanGeometry = new THREE.BufferGeometry() cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) @@ -80,9 +78,14 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { } function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) { + if (type === 'window') { + const node = useScene.getState().nodes[nodeId as AnyNodeId] + return node?.type === 'window' && node.openingKind === 'opening' + } + if (type !== 'door') return false - const node = useScene.getState().nodes[nodeId] + const node = useScene.getState().nodes[nodeId as AnyNodeId] if (!node || node.type !== 'door') return false if (node.openingKind === 'opening') return true @@ -92,6 +95,42 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE return node.segments.every((segment) => segment.type === 'empty') } +function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { + const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty') + if (!hasLeafContent) return null + + const leafW = node.width - 2 * node.frameThickness + const leafH = node.height - node.frameThickness + if (leafW <= 0 || leafH <= 0) return null + + const leafCenterY = -node.frameThickness / 2 + const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 + const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 + const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 + const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0)) + const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign + + root.updateWorldMatrix(true, false) + + const sourceGeometry = new THREE.BoxGeometry( + leafW, + leafH, + DOOR_LEAF_COLLIDER_DEPTH, + ).toNonIndexed() + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) + geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) + sourceGeometry.dispose() + const matrix = root.matrixWorld + .clone() + .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0)) + .multiply(new THREE.Matrix4().makeRotationY(leafSwingRotation)) + .multiply(new THREE.Matrix4().makeTranslation(-hingeX, leafCenterY, 0)) + + geometry.applyMatrix4(matrix) + return geometry +} + function buildRegisteredNodeTypeLookup() { const nodeTypes = new Map() @@ -164,6 +203,17 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider const root = sceneRegistry.nodes.get(nodeId) if (!root) continue + if (type === 'door') { + const node = useScene.getState().nodes[nodeId as AnyNodeId] + if (node?.type !== 'door') continue + + const doorGeometry = createDoorLeafColliderGeometry(root, node) + if (doorGeometry) { + geometries.push(doorGeometry) + } + continue + } + root.updateMatrixWorld(true) geometries.push( ...collectColliderGeometriesFromNode( @@ -182,7 +232,9 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider } const mergedGeometry = mergeGeometries(geometries, false) - geometries.forEach((geometry) => geometry.dispose()) + geometries.forEach((geometry) => { + geometry.dispose() + }) if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) { mergedGeometry?.dispose() @@ -247,7 +299,8 @@ export function deriveFirstPersonSpawn( } for (const [x, z] of candidates) { - const topY = Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE + const topY = + Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE raycaster.set(new THREE.Vector3(x, topY, z), DOWN) const intersections = raycaster.intersectObject(world.mesh, false) const hit = intersections.find((intersection) => { @@ -265,11 +318,7 @@ export function deriveFirstPersonSpawn( } return { - position: [ - camera.position.x, - Math.max(camera.position.y, SPAWN_EYE_HEIGHT), - camera.position.z, - ], + position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z], yaw, } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index d77dffee..bf3b5f9c 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -4154,6 +4154,137 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ const detailStrokeWidth = isSelected || isSelectionHighlighted ? '1.05' : '0.75' const markerX = (p1!.x + p2!.x + p3!.x + p4!.x) / 4 const markerY = (p1!.y + p2!.y + p3!.y + p4!.y) / 4 + const windowOpeningShape = opening.openingShape ?? 'rectangle' + + if (opening.openingKind === 'opening') { + const detailInset = Math.min(tangentLength * 0.14, 0.18) + const detailStart = { + x: centerLine.start.x + tangentX * detailInset, + y: centerLine.start.y + tangentY * detailInset, + } + const detailEnd = { + x: centerLine.end.x - tangentX * detailInset, + y: centerLine.end.y - tangentY * detailInset, + } + const detailControl = { + x: (detailStart.x + detailEnd.x) / 2 + normalX * normalLength * 0.34, + y: (detailStart.y + detailEnd.y) / 2 + normalY * normalLength * 0.34, + } + const detailPath = + windowOpeningShape === 'rectangle' + ? null + : `M ${toSvgX(detailStart.x)} ${toSvgY(detailStart.y)} Q ${toSvgX(detailControl.x)} ${toSvgY(detailControl.y)} ${toSvgX(detailEnd.x)} ${toSvgY(detailEnd.y)}` + + return ( + { + event.stopPropagation() + onOpeningSelect(opening.id, event) + } + : undefined + } + onDoubleClick={ + canFocusGeometry + ? (event) => { + event.stopPropagation() + onOpeningDoubleClick(opening) + } + : undefined + } + onPointerDown={ + canFocusGeometry && isSelected + ? (event) => { + if (event.button === 0) { + onOpeningPointerDown(opening.id, event) + } + } + : undefined + } + onPointerEnter={ + canSelectGeometry + ? () => { + onWallHoverChange(null) + onOpeningHoverChange(opening.id) + } + : undefined + } + onPointerLeave={canSelectGeometry ? () => onOpeningHoverChange(null) : undefined} + style={{ cursor: EDITOR_CURSOR }} + > + {canSelectGeometry && ( + + )} + + {detailPath ? ( + + ) : ( + + )} + {isSelected ? ( + <> + + + + + ) : null} + + ) + } return ( `${point.x},${point.y}`) .join(' ') + const swingSweepPath = + swingRadius > 1e-6 + ? `M ${leafStart.x} ${leafStart.y} L ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y} Z` + : null + const jambTickSize = doorCubeSize * 0.82 + const hingeMarkerRadius = Math.min(Math.max(doorCubeSize * 0.22, 0.018), 0.034) + const strikeTickStart = { + x: strikeCubeCenter.x - px * swingSign * jambTickSize * 0.5, + y: strikeCubeCenter.y - py * swingSign * jambTickSize * 0.5, + } + const strikeTickEnd = { + x: strikeCubeCenter.x + px * swingSign * jambTickSize * 0.5, + y: strikeCubeCenter.y + py * swingSign * jambTickSize * 0.5, + } + const closedLeafHintPoints = [ + { + x: leafStart.x - nx * leafHalfThickness * 0.7, + y: leafStart.y - ny * leafHalfThickness * 0.7, + }, + { + x: arcEnd.x - nx * leafHalfThickness * 0.7, + y: arcEnd.y - ny * leafHalfThickness * 0.7, + }, + { + x: arcEnd.x + nx * leafHalfThickness * 0.7, + y: arcEnd.y + ny * leafHalfThickness * 0.7, + }, + { + x: leafStart.x + nx * leafHalfThickness * 0.7, + y: leafStart.y + ny * leafHalfThickness * 0.7, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + const openingCenterLineStart = { + x: (svgP1.x + svgP4.x) / 2, + y: (svgP1.y + svgP4.y) / 2, + } + const openingCenterLineEnd = { + x: (svgP2.x + svgP3.x) / 2, + y: (svgP2.y + svgP3.y) / 2, + } return ( ) : ( )} + {archPlanPath && ( @@ -4572,36 +4774,108 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ ) : ( <> - + + {swingSweepPath && ( + + )} + {swingAngle > 0.03 && ( + + )} {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( ))} + + )} + {isSelected ? ( + <> + + + + + ) : null} ) } diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index e2bb41d8..12a1f954 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -15,6 +15,7 @@ interface SliderControlProps { step?: number className?: string unit?: string + restoreOnCommit?: boolean } function stepPrecision(s: number): number { @@ -56,6 +57,7 @@ export function SliderControl({ step = 1, className, unit = '', + restoreOnCommit = true, }: SliderControlProps) { const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) @@ -161,7 +163,10 @@ export function SliderControl({ const newValue = clamp( Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))), ) - onChange(newValue) + if (newValue !== valueRef.current) { + valueRef.current = newValue + onChange(newValue) + } }, [step, clamp, onChange], ) @@ -175,7 +180,7 @@ export function SliderControl({ setIsDragging(false) e.currentTarget.releasePointerCapture(e.pointerId) - if (originValue !== finalVal) { + if (originValue !== finalVal && restoreOnCommit) { onChange(originValue) useScene.temporal.getState().resume() onChange(finalVal) @@ -185,7 +190,7 @@ export function SliderControl({ onCommit?.(finalVal) } }, - [onChange, onCommit], + [onChange, onCommit, restoreOnCommit], ) const handleValueClick = useCallback(() => { diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 22142ee4..363a859c 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -9,7 +9,7 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { usePresetsAdapter } from '../../../contexts/presets-context' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' @@ -22,12 +22,32 @@ import { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' import { PresetsPopover } from './presets/presets-popover' +function isSameDoorValue(current: unknown, next: unknown): boolean { + if (typeof current === 'number' && typeof next === 'number') { + return Math.abs(current - next) < 1e-6 + } + + if (Array.isArray(current) && Array.isArray(next)) { + return ( + current.length === next.length && + current.every((value, index) => isSameDoorValue(value, next[index])) + ) + } + + return Object.is(current, next) +} + export function DoorPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const previewRef = useRef<{ + id: AnyNodeId + key: keyof DoorNode + value: unknown + } | null>(null) const adapter = usePresetsAdapter() @@ -37,10 +57,57 @@ export function DoorPanel() { const handleUpdate = useCallback( (updates: Partial) => { - if (!selectedId) return + if (!(selectedId && node)) return + const hasChange = Object.entries(updates).some(([key, value]) => { + const currentValue = node[key as keyof DoorNode] + return !isSameDoorValue(currentValue, value) + }) + if (!hasChange) return + updateNode(selectedId as AnyNode['id'], updates) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) }, + [selectedId, node, updateNode], + ) + + const previewDoorUpdate = useCallback( + (key: K, value: DoorNode[K]) => { + if (!selectedId) return + const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] + if (liveNode?.type !== 'door') return + + if (!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)) { + previewRef.current = { + id: selectedId as AnyNodeId, + key, + value: liveNode[key], + } + } + + if (isSameDoorValue(liveNode[key], value)) return + + ;(liveNode as DoorNode)[key] = value + useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + }, + [selectedId], + ) + + const commitDoorPreview = useCallback( + (key: K, value: DoorNode[K]) => { + if (!selectedId) return + + const scene = useScene.getState() + const liveNode = scene.nodes[selectedId as AnyNodeId] + const preview = previewRef.current + if (liveNode?.type === 'door' && preview?.id === selectedId && preview.key === key) { + ;(liveNode as DoorNode)[key] = preview.value as DoorNode[K] + scene.dirtyNodes.add(selectedId as AnyNodeId) + } + previewRef.current = null + + updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) + scene.dirtyNodes.add(selectedId as AnyNodeId) + }, [selectedId, updateNode], ) @@ -134,6 +201,8 @@ export function DoorPanel() { frameDepth: node.frameDepth, openingKind: node.openingKind, openingShape: node.openingShape, + openingRadiusMode: node.openingRadiusMode ?? 'all', + openingTopRadii: node.openingTopRadii ?? [0.15, 0.15], cornerRadius: node.cornerRadius, archHeight: node.archHeight, openingRevealRadius: node.openingRevealRadius, @@ -185,9 +254,22 @@ export function DoorPanel() { const normHeights = node.segments.map((seg) => seg.heightRatio / hSum) const isOpening = node.openingKind === 'opening' const openingShape = node.openingShape ?? 'rectangle' + const openingRadiusMode = node.openingRadiusMode ?? 'all' + const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15] const cornerRadius = node.cornerRadius ?? 0.15 const archHeight = node.archHeight ?? 0.45 const openingRevealRadius = node.openingRevealRadius ?? 0.025 + const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height)) + + const setOpeningTopRadius = (index: number, value: number, commit = false) => { + const next = [...openingTopRadii] as [number, number] + next[index] = value + if (commit) { + commitDoorPreview('openingTopRadii', next) + } else { + previewDoorUpdate('openingTopRadii', next) + } + } return ( handleUpdate({ width: v })} precision={2} + restoreOnCommit={false} step={0.05} unit="m" value={Math.round(node.width * 100) / 100} @@ -288,6 +373,7 @@ export function DoorPanel() { handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] }) } precision={2} + restoreOnCommit={false} step={0.05} unit="m" value={Math.round(node.height * 100) / 100} @@ -301,7 +387,9 @@ export function DoorPanel() { onChange={(v) => handleUpdate({ openingShape: v, - ...(v === 'rounded' ? { cornerRadius, openingRevealRadius } : {}), + ...(v === 'rounded' + ? { openingRadiusMode, openingTopRadii, cornerRadius, openingRevealRadius } + : {}), ...(v === 'arch' ? { archHeight } : {}), }) } @@ -315,21 +403,57 @@ export function DoorPanel() {
{openingShape === 'rounded' && ( <> - handleUpdate({ cornerRadius: v })} - precision={2} - step={0.05} - unit="m" - value={Math.round(cornerRadius * 100) / 100} - /> +
+ + handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] }) + } + options={[ + { label: 'All', value: 'all' }, + { label: 'Individual', value: 'individual' }, + ]} + value={openingRadiusMode} + /> +
+ {openingRadiusMode === 'all' ? ( + previewDoorUpdate('cornerRadius', v)} + onCommit={(v) => commitDoorPreview('cornerRadius', v)} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + ) : ( + <> + {[ + ['Top Left', 0], + ['Top Right', 1], + ].map(([label, index]) => ( + setOpeningTopRadius(index as number, v)} + onCommit={(v) => setOpeningTopRadius(index as number, v, true)} + precision={2} + step={0.05} + unit="m" + value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100} + /> + ))} + + )} handleUpdate({ openingRevealRadius: v })} + onChange={(v) => previewDoorUpdate('openingRevealRadius', v)} + onCommit={(v) => commitDoorPreview('openingRevealRadius', v)} precision={3} step={0.005} unit="m" diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index a41891b0..53162017 100755 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -9,24 +9,75 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { usePresetsAdapter } from '../../../contexts/presets-context' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' +import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' import { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' import { PresetsPopover } from './presets/presets-popover' +function isSameWindowValue(current: unknown, next: unknown): boolean { + if (typeof current === 'number' && typeof next === 'number') { + return Math.abs(current - next) < 1e-6 + } + + if (Array.isArray(current) && Array.isArray(next)) { + return ( + current.length === next.length && + current.every((value, index) => isSameWindowValue(value, next[index])) + ) + } + + return Object.is(current, next) +} + +function getMaxSharedWindowRadius(width: number, height: number) { + return Math.max(0, Math.min(width / 2, height / 2)) +} + +function normalizeWindowCornerRadii( + radii: [number, number, number, number], + width: number, + height: number, +): [number, number, number, number] { + const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number] + const scale = Math.min( + 1, + Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6), + Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6), + Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6), + Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6), + ) + + if (scale >= 1) return next + + return next.map((radius) => radius * scale) as [number, number, number, number] +} + +function isSameRadiusTuple( + current: [number, number, number, number], + next: [number, number, number, number], +) { + return current.every((value, index) => Math.abs(value - next[index]) < 1e-6) +} + export function WindowPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const previewRef = useRef<{ + id: AnyNodeId + key: keyof WindowNode + value: unknown + } | null>(null) const adapter = usePresetsAdapter() @@ -36,10 +87,59 @@ export function WindowPanel() { const handleUpdate = useCallback( (updates: Partial) => { - if (!selectedId) return + if (!(selectedId && node)) return + const hasChange = Object.entries(updates).some(([key, value]) => { + const currentValue = node[key as keyof WindowNode] + return !isSameWindowValue(currentValue, value) + }) + if (!hasChange) return + updateNode(selectedId as AnyNode['id'], updates) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) }, + [selectedId, node, updateNode], + ) + + const previewWindowUpdate = useCallback( + (key: K, value: WindowNode[K]) => { + if (!selectedId) return + const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] + if (liveNode?.type !== 'window') return + + if ( + !(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key) + ) { + previewRef.current = { + id: selectedId as AnyNodeId, + key, + value: liveNode[key], + } + } + + if (isSameWindowValue(liveNode[key], value)) return + + ;(liveNode as WindowNode)[key] = value + useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + }, + [selectedId], + ) + + const commitWindowPreview = useCallback( + (key: K, value: WindowNode[K]) => { + if (!selectedId) return + + const scene = useScene.getState() + const liveNode = scene.nodes[selectedId as AnyNodeId] + const preview = previewRef.current + if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) { + ;(liveNode as WindowNode)[key] = preview.value as WindowNode[K] + scene.dirtyNodes.add(selectedId as AnyNodeId) + } + previewRef.current = null + + updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) + scene.dirtyNodes.add(selectedId as AnyNodeId) + }, [selectedId, updateNode], ) @@ -84,6 +184,13 @@ export function WindowPanel() { height: node.height, frameThickness: node.frameThickness, frameDepth: node.frameDepth, + openingKind: node.openingKind, + openingShape: node.openingShape, + openingRadiusMode: node.openingRadiusMode ?? 'all', + openingCornerRadii: [...(node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15])], + cornerRadius: node.cornerRadius, + archHeight: node.archHeight, + openingRevealRadius: node.openingRevealRadius, columnRatios: [...node.columnRatios], rowRatios: [...node.rowRatios], columnDividerThickness: node.columnDividerThickness, @@ -105,6 +212,13 @@ export function WindowPanel() { height: node.height, frameThickness: node.frameThickness, frameDepth: node.frameDepth, + openingKind: node.openingKind, + openingShape: node.openingShape, + openingRadiusMode: node.openingRadiusMode ?? 'all', + openingCornerRadii: node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15], + cornerRadius: node.cornerRadius, + archHeight: node.archHeight, + openingRevealRadius: node.openingRevealRadius, columnRatios: node.columnRatios, rowRatios: node.rowRatios, columnDividerThickness: node.columnDividerThickness, @@ -151,6 +265,58 @@ export function WindowPanel() { const rowSum = node.rowRatios.reduce((a, b) => a + b, 0) const normCols = node.columnRatios.map((r) => r / colSum) const normRows = node.rowRatios.map((r) => r / rowSum) + const isOpening = node.openingKind === 'opening' + const openingShape = node.openingShape ?? 'rectangle' + const openingRadiusMode = node.openingRadiusMode ?? 'all' + const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] + const cornerRadius = node.cornerRadius ?? 0.15 + const archHeight = node.archHeight ?? 0.35 + const openingRevealRadius = node.openingRevealRadius ?? 0.025 + const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height)) + + const getDimensionUpdates = (updates: Partial>) => { + const nextWidth = updates.width ?? node.width + const nextHeight = updates.height ?? node.height + const nextUpdates: Partial = { ...updates } + + if (openingShape === 'rounded') { + if (openingRadiusMode === 'individual') { + const currentRadii = openingCornerRadii as [number, number, number, number] + const nextRadii = normalizeWindowCornerRadii( + openingCornerRadii as [number, number, number, number], + nextWidth, + nextHeight, + ) + if (!isSameRadiusTuple(currentRadii, nextRadii)) { + nextUpdates.openingCornerRadii = nextRadii + } + } else { + const nextRadius = Math.min(Math.max(cornerRadius, 0), getMaxSharedWindowRadius(nextWidth, nextHeight)) + if (Math.abs(nextRadius - cornerRadius) > 1e-6) { + nextUpdates.cornerRadius = nextRadius + } + } + } + + if (openingShape === 'arch') { + const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05)) + if (Math.abs(nextArchHeight - archHeight) > 1e-6) { + nextUpdates.archHeight = nextArchHeight + } + } + + return nextUpdates + } + + const setOpeningCornerRadius = (index: number, value: number, commit = false) => { + const next = [...openingCornerRadii] as [number, number, number, number] + next[index] = value + if (commit) { + commitWindowPreview('openingCornerRadii', next) + } else { + previewWindowUpdate('openingCornerRadii', next) + } + } const setColumnRatio = (index: number, newVal: number) => { const clamped = Math.max(0.05, Math.min(0.95, newVal)) @@ -206,6 +372,31 @@ export function WindowPanel() {
+ + + handleUpdate({ + openingKind: value as WindowNode['openingKind'], + ...(value === 'opening' + ? { + openingShape, + openingRadiusMode, + openingCornerRadii, + cornerRadius, + archHeight, + openingRevealRadius, + } + : {}), + }) + } + options={[ + { value: 'window', label: 'Window' }, + { value: 'opening', label: 'Opening' }, + ]} + value={node.openingKind ?? 'window'} + /> + + -
- } - label="Flip Side" - onClick={handleFlip} - /> -
+ {!isOpening && ( +
+ } + label="Flip Side" + onClick={handleFlip} + /> +
+ )}
handleUpdate({ width: v })} + onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))} precision={2} + restoreOnCommit={false} step={0.1} unit="m" value={Math.round(node.width * 100) / 100} @@ -254,157 +448,252 @@ export function WindowPanel() { handleUpdate({ height: v })} + onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))} precision={2} + restoreOnCommit={false} step={0.1} unit="m" value={Math.round(node.height * 100) / 100} /> - - handleUpdate({ frameThickness: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.frameThickness * 1000) / 1000} - /> - handleUpdate({ frameDepth: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.frameDepth * 1000) / 1000} - /> - - - - { - const n = Math.max(1, Math.min(8, Math.round(v))) - handleUpdate({ columnRatios: Array(n).fill(1 / n) }) - }} - precision={0} - step={1} - value={numCols} - /> - { - const n = Math.max(1, Math.min(8, Math.round(v))) - handleUpdate({ rowRatios: Array(n).fill(1 / n) }) - }} - precision={0} - step={1} - value={numRows} - /> - - {numCols > 1 && ( -
-
- Col Widths -
- {normCols.map((ratio, i) => ( - setColumnRatio(i, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(ratio * 100 * 10) / 10} + {isOpening && ( + + + handleUpdate({ openingShape: value as WindowNode['openingShape'] }) + } + options={[ + { value: 'rectangle', label: 'Rect' }, + { value: 'rounded', label: 'Rounded' }, + { value: 'arch', label: 'Arch' }, + ]} + value={openingShape} + /> + {openingShape === 'rounded' && ( +
+ + handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) + } + options={[ + { value: 'all', label: 'All' }, + { value: 'individual', label: 'Individual' }, + ]} + value={openingRadiusMode} /> - ))} -
+ {openingRadiusMode === 'all' ? ( + previewWindowUpdate('cornerRadius', value)} + onCommit={(value) => commitWindowPreview('cornerRadius', value)} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + ) : ( + <> + {[ + ['Top Left', 0], + ['Top Right', 1], + ['Bottom Right', 2], + ['Bottom Left', 3], + ].map(([label, index]) => ( + setOpeningCornerRadius(index as number, value)} + onCommit={(value) => setOpeningCornerRadius(index as number, value, true)} + precision={2} + step={0.05} + unit="m" + value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100} + /> + ))} + + )} handleUpdate({ columnDividerThickness: v })} + label="Reveal Radius" + max={0.08} + min={0} + onChange={(value) => previewWindowUpdate('openingRevealRadius', value)} + onCommit={(value) => commitWindowPreview('openingRevealRadius', value)} precision={3} - step={0.01} + step={0.005} unit="m" - value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} + value={Math.round(openingRevealRadius * 1000) / 1000} />
-
- )} - - {numRows > 1 && ( -
-
- Row Heights -
- {normRows.map((ratio, i) => ( + )} + {openingShape === 'arch' && ( +
setRowRatio(i, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(ratio * 100 * 10) / 10} - /> - ))} -
- handleUpdate({ rowDividerThickness: v })} - precision={3} - step={0.01} + label="Arch Height" + max={Math.max(0.05, node.height)} + min={0.05} + onChange={(value) => handleUpdate({ archHeight: value })} + precision={2} + step={0.05} unit="m" - value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} + value={Math.round(archHeight * 100) / 100} />
-
- )} - + )} + + )} - - handleUpdate({ sill: checked })} - /> - {node.sill && ( -
+ {!isOpening && ( + <> + handleUpdate({ sillDepth: v })} + onChange={(v) => handleUpdate({ frameThickness: v })} precision={3} step={0.01} unit="m" - value={Math.round(node.sillDepth * 1000) / 1000} + value={Math.round(node.frameThickness * 1000) / 1000} /> handleUpdate({ sillThickness: v })} + onChange={(v) => handleUpdate({ frameDepth: v })} precision={3} step={0.01} unit="m" - value={Math.round(node.sillThickness * 1000) / 1000} + value={Math.round(node.frameDepth * 1000) / 1000} /> -
- )} -
+ + + + { + const n = Math.max(1, Math.min(8, Math.round(v))) + handleUpdate({ columnRatios: Array(n).fill(1 / n) }) + }} + precision={0} + step={1} + value={numCols} + /> + { + const n = Math.max(1, Math.min(8, Math.round(v))) + handleUpdate({ rowRatios: Array(n).fill(1 / n) }) + }} + precision={0} + step={1} + value={numRows} + /> + + {numCols > 1 && ( +
+
+ Col Widths +
+ {normCols.map((ratio, i) => ( + setColumnRatio(i, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(ratio * 100 * 10) / 10} + /> + ))} +
+ handleUpdate({ columnDividerThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} + /> +
+
+ )} + + {numRows > 1 && ( +
+
+ Row Heights +
+ {normRows.map((ratio, i) => ( + setRowRatio(i, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(ratio * 100 * 10) / 10} + /> + ))} +
+ handleUpdate({ rowDividerThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} + /> +
+
+ )} +
+ + + handleUpdate({ sill: checked })} + /> + {node.sill && ( +
+ handleUpdate({ sillDepth: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round(node.sillDepth * 1000) / 1000} + /> + handleUpdate({ sillThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round(node.sillThickness * 1000) / 1000} + /> +
+ )} +
+ + )} From eee7af9e254bb5fd363023760290fdc0fcf8afe1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 1 May 2026 14:55:24 +0530 Subject: [PATCH 12/13] Add draggable level reordering to floating selector --- bun.lock | 11 + packages/editor/package.json | 3 + .../components/ui/floating-level-selector.tsx | 273 ++++++++++++++---- 3 files changed, 232 insertions(+), 55 deletions(-) diff --git a/bun.lock b/bun.lock index fc2f7c2f..0b9d6554 100644 --- a/bun.lock +++ b/bun.lock @@ -79,6 +79,9 @@ "name": "@pascal-app/editor", "version": "0.6.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -278,6 +281,14 @@ "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], diff --git a/packages/editor/package.json b/packages/editor/package.json index dbe2e5d0..24ee4e8b 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -21,6 +21,9 @@ "three": "^0.184" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index a11ff7be..c348a303 100755 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -1,5 +1,23 @@ 'use client' +import { + closestCenter, + DndContext, + type DragEndEvent, + type DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { type AnyNode, type AnyNodeId, @@ -8,8 +26,15 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Copy, MoreVertical, Plus, Trash2 } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' +import { + type ButtonHTMLAttributes, + type CSSProperties, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import { useShallow } from 'zustand/react/shallow' import { buildLevelDuplicateCreateOps, @@ -96,12 +121,18 @@ function LevelInlineRename({ function LevelRow({ level, isSelected, + isDragging, + dragHandleProps, + dragHandleRef, onSelect, onDuplicate, onRequestDelete, }: { level: LevelNode isSelected: boolean + isDragging?: boolean + dragHandleProps?: ButtonHTMLAttributes + dragHandleRef?: (element: HTMLButtonElement | null) => void onSelect: () => void onDuplicate: (preset?: LevelDuplicatePreset) => void onRequestDelete: () => void @@ -121,13 +152,32 @@ function LevelRow({
+ + + {!draggingLevelId && ( + + )} {/* Floating + at bottom edge */} - + {!draggingLevelId && ( + + )} {/* Level list */} -
- {reversedLevels.map((level, i) => { - const isSelected = level.id === levelId - const sortedIndex = levels.indexOf(level) - const showGapBelow = i < reversedLevels.length - 1 - - return ( -
- handleDuplicateLevel(level, preset)} - onRequestDelete={() => setDeletingLevel(level)} - onSelect={() => - setSelection( - resolvedBuildingId - ? { buildingId: resolvedBuildingId, levelId: level.id } - : { levelId: level.id }, - ) - } - /> - - {showGapBelow && ( - - )} -
- ) - })} -
+ + +
+ {reversedLevels.map((level, i) => { + const isSelected = level.id === levelId + const sortedIndex = levels.indexOf(level) + const showGapBelow = i < reversedLevels.length - 1 + + return ( +
+ handleDuplicateLevel(level, preset)} + onRequestDelete={() => setDeletingLevel(level)} + onSelect={() => + setSelection( + resolvedBuildingId + ? { buildingId: resolvedBuildingId, levelId: level.id } + : { levelId: level.id }, + ) + } + /> + + {showGapBelow && !draggingLevelId && ( + + )} +
+ ) + })} +
+
+
From 23eec44714bd71456f7b366fa6b21f9864d1279d Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 1 May 2026 15:07:47 +0530 Subject: [PATCH 13/13] Move render systems out of core --- bun.lock | 4 +- packages/core/package.json | 2 - packages/core/src/index.ts | 12 +----- packages/core/src/materials.ts | 24 ------------ packages/viewer/package.json | 2 + .../renderers/item/item-renderer.tsx | 3 +- .../viewer/src/components/viewer/index.tsx | 20 +++++----- packages/viewer/src/lib/materials.ts | 16 ++++++++ .../src/systems/ceiling/ceiling-system.tsx | 4 +- .../src/systems/door/door-system.tsx | 11 ++++-- .../src/systems/fence/fence-system.tsx | 12 ++++-- .../src/systems/item/item-system.tsx | 15 +++++--- .../src/systems/roof/roof-system.tsx | 30 +++++++++------ .../src/systems/slab/slab-system.tsx | 11 ++++-- .../src/systems/stair/stair-system.tsx | 17 ++++++--- .../viewer/src/systems/wall/wall-materials.ts | 3 +- .../src/systems/wall/wall-system.tsx | 38 ++++++++++++------- .../src/systems/window/window-system.tsx | 11 ++++-- 18 files changed, 126 insertions(+), 109 deletions(-) delete mode 100644 packages/core/src/materials.ts rename packages/{core => viewer}/src/systems/ceiling/ceiling-system.tsx (94%) rename packages/{core => viewer}/src/systems/door/door-system.tsx (97%) rename packages/{core => viewer}/src/systems/fence/fence-system.tsx (96%) rename packages/{core => viewer}/src/systems/item/item-system.tsx (84%) rename packages/{core => viewer}/src/systems/roof/roof-system.tsx (97%) rename packages/{core => viewer}/src/systems/slab/slab-system.tsx (96%) rename packages/{core => viewer}/src/systems/stair/stair-system.tsx (98%) rename packages/{core => viewer}/src/systems/wall/wall-system.tsx (96%) rename packages/{core => viewer}/src/systems/window/window-system.tsx (96%) diff --git a/bun.lock b/bun.lock index 0b9d6554..c0e5fbbe 100644 --- a/bun.lock +++ b/bun.lock @@ -56,8 +56,6 @@ "idb-keyval": "^6.2.2", "mitt": "^3.0.1", "nanoid": "^5.1.6", - "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.8", "zod": "^4.3.5", "zundo": "^2.3.0", "zustand": "^5", @@ -196,6 +194,8 @@ "version": "0.6.0", "dependencies": { "polygon-clipping": "^0.15.7", + "three-bvh-csg": "^0.0.18", + "three-mesh-bvh": "^0.9.8", "zustand": "^5", }, "devDependencies": { diff --git a/packages/core/package.json b/packages/core/package.json index 13128823..8cffdb40 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -67,8 +67,6 @@ "idb-keyval": "^6.2.2", "mitt": "^3.0.1", "nanoid": "^5.1.6", - "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.8", "zod": "^4.3.5", "zundo": "^2.3.0", "zustand": "^5" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e81a36bb..59e84f2b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,7 +52,6 @@ export { type MaterialCategory, toLibraryMaterialRef, } from './material-library' -export { baseMaterial, glassMaterial } from './materials' export * from './schema' export { getSceneHistoryPauseDepth, @@ -67,13 +66,7 @@ export { } from './store/use-interactive' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' -export { CeilingSystem } from './systems/ceiling/ceiling-system' -export { DoorSystem } from './systems/door/door-system' -export { FenceSystem } from './systems/fence/fence-system' -export { ItemSystem } from './systems/item/item-system' -export { RoofSystem } from './systems/roof/roof-system' -export { SlabSystem } from './systems/slab/slab-system' -export { StairSystem } from './systems/stair/stair-system' +export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { getClampedWallCurveOffset, getMaxWallCurveOffset, @@ -95,14 +88,13 @@ export { } from './systems/wall/wall-footprint' export { calculateLevelMiters, + getAdjacentWallIds, getWallMiterBoundaryPoints, type Point2D, pointToKey, type WallMiterBoundaryPoints, type WallMiterData, } from './systems/wall/wall-mitering' -export { WallSystem } from './systems/wall/wall-system' -export { WindowSystem } from './systems/window/window-system' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/materials.ts b/packages/core/src/materials.ts deleted file mode 100644 index 089d2677..00000000 --- a/packages/core/src/materials.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' - -/** - * Shared base material for structural elements: walls, frames, slabs, roof. - */ -export const baseMaterial = new MeshStandardNodeMaterial({ - color: '#f2f0ed', - roughness: 0.5, - metalness: 0, -}) - -/** - * Shared glass material for windows, glazed door panels, and glass items. - */ -export const glassMaterial = new MeshStandardNodeMaterial({ - name: 'glass', - color: 'lightblue', - roughness: 0.05, - metalness: 0.1, - transparent: true, - opacity: 0.35, - side: DoubleSide, - depthWrite: false, -}) diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 1c214282..77c4ed75 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -30,6 +30,8 @@ }, "dependencies": { "polygon-clipping": "^0.15.7", + "three-bvh-csg": "^0.0.18", + "three-mesh-bvh": "^0.9.8", "zustand": "^5" }, "devDependencies": { diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 5bcfbd2d..86b44d23 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -1,8 +1,6 @@ import { type AnimationEffect, type AnyNodeId, - baseMaterial, - glassMaterial, type Interactive, type ItemNode, type LightEffect, @@ -21,6 +19,7 @@ import { positionLocal, smoothstep, time } from 'three/tsl' import { MeshStandardNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveCdnUrl } from '../../../lib/asset-url' +import { baseMaterial, glassMaterial } from '../../../lib/materials' import { useItemLightPool } from '../../../store/use-item-light-pool' import { requestItemMeshMetadataSync, diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 1a77e688..13f478b5 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,27 +1,25 @@ 'use client' -import { - CeilingSystem, - DoorSystem, - FenceSystem, - ItemSystem, - RoofSystem, - SlabSystem, - StairSystem, - WallSystem, - WindowSystem, -} from '@pascal-app/core' import { Bvh } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' import useViewer from '../../store/use-viewer' +import { CeilingSystem } from '../../systems/ceiling/ceiling-system' +import { DoorSystem } from '../../systems/door/door-system' +import { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' +import { ItemSystem } from '../../systems/item/item-system' import { ItemLightSystem } from '../../systems/item-light/item-light-system' import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system' import { LevelSystem } from '../../systems/level/level-system' +import { RoofSystem } from '../../systems/roof/roof-system' import { ScanSystem } from '../../systems/scan/scan-system' +import { SlabSystem } from '../../systems/slab/slab-system' +import { StairSystem } from '../../systems/stair/stair-system' import { WallCutout } from '../../systems/wall/wall-cutout' +import { WallSystem } from '../../systems/wall/wall-system' +import { WindowSystem } from '../../systems/window/window-system' import { ZoneSystem } from '../../systems/zone/zone-system' import { ErrorBoundary } from '../error-boundary' import { SceneRenderer } from '../renderers/scene-renderer' diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 0aec65dd..b390e436 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -7,6 +7,22 @@ import { resolveMaterial, } from '@pascal-app/core' import * as THREE from 'three' +import { MeshStandardNodeMaterial } from 'three/webgpu' + +export const baseMaterial = new MeshStandardNodeMaterial({ + color: '#f2f0ed', + roughness: 0.5, + metalness: 0.0, +}) + +export const glassMaterial = new MeshStandardNodeMaterial({ + color: '#e0f2fe', + roughness: 0.05, + metalness: 0.0, + transparent: true, + opacity: 0.35, + side: THREE.DoubleSide, +}) const sideMap: Record = { front: THREE.FrontSide, diff --git a/packages/core/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx similarity index 94% rename from packages/core/src/systems/ceiling/ceiling-system.tsx rename to packages/viewer/src/systems/ceiling/ceiling-system.tsx index 4f47fab1..5e383d2b 100644 --- a/packages/core/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -1,8 +1,6 @@ import { useFrame } from '@react-three/fiber' +import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core' import * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import type { AnyNodeId, CeilingNode } from '../../schema' -import useScene from '../../store/use-scene' function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx similarity index 97% rename from packages/core/src/systems/door/door-system.tsx rename to packages/viewer/src/systems/door/door-system.tsx index 93f4abd4..a782ed95 100644 --- a/packages/core/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1,9 +1,12 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + type DoorNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' import * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { baseMaterial, glassMaterial } from '../../materials' -import type { AnyNodeId, DoorNode } from '../../schema' -import useScene from '../../store/use-scene' +import { baseMaterial, glassMaterial } from '../../lib/materials' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) diff --git a/packages/core/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx similarity index 96% rename from packages/core/src/systems/fence/fence-system.tsx rename to packages/viewer/src/systems/fence/fence-system.tsx index 4fcc1a86..c0a2605c 100644 --- a/packages/core/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -1,10 +1,14 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + type FenceNode, + getWallCurveFrameAt, + getWallCurveLength, + sceneRegistry, + useScene, +} from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import type { AnyNodeId, FenceNode } from '../../schema' -import useScene from '../../store/use-scene' -import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve' type FencePart = { position: [number, number, number] diff --git a/packages/core/src/systems/item/item-system.tsx b/packages/viewer/src/systems/item/item-system.tsx similarity index 84% rename from packages/core/src/systems/item/item-system.tsx rename to packages/viewer/src/systems/item/item-system.tsx index dbfbf7e3..75173160 100644 --- a/packages/core/src/systems/item/item-system.tsx +++ b/packages/viewer/src/systems/item/item-system.tsx @@ -1,10 +1,15 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + getScaledDimensions, + type ItemNode, + resolveLevelId, + sceneRegistry, + spatialGridManager, + useScene, + type WallNode, +} from '@pascal-app/core' import type * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' -import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import { type AnyNodeId, getScaledDimensions, type ItemNode, type WallNode } from '../../schema' -import useScene from '../../store/use-scene' // ============================================================================ // ITEM SYSTEM diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx similarity index 97% rename from packages/core/src/systems/roof/roof-system.tsx rename to packages/viewer/src/systems/roof/roof-system.tsx index 5a1f06d1..a7479fb0 100644 --- a/packages/core/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1,21 +1,30 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNode, + type AnyNodeId, + type RoofNode, + type RoofSegmentNode, + type RoofType, + sceneRegistry, + useScene, +} from '@pascal-app/core' import * as THREE from 'three' import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '../../schema' -import type { RoofType } from '../../schema/nodes/roof-segment' -import useScene from '../../store/use-scene' const csgEvaluator = new Evaluator() csgEvaluator.useGroups = true ;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash csgEvaluator.attributes = ['position', 'normal', 'uv'] +function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { + ;(geometry as any).computeBoundsTree = computeBoundsTree + ;(geometry as any).computeBoundsTree({ maxLeafSize: 10 }) +} + function prepareBrushForCSG(brush: Brush) { - brush.geometry.computeBoundsTree = computeBoundsTree - brush.geometry.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(brush.geometry) brush.updateMatrixWorld() } @@ -78,8 +87,7 @@ export const RoofSystem = () => { mesh.geometry.dispose() const placeholder = new THREE.BufferGeometry() placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - placeholder.computeBoundsTree = computeBoundsTree - placeholder.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(placeholder) mesh.geometry = placeholder } mesh.position.set(node.position[0], node.position[1], node.position[2]) @@ -134,8 +142,7 @@ function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) { mesh.geometry.dispose() mesh.geometry = newGeo - newGeo.computeBoundsTree = computeBoundsTree - newGeo.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(newGeo) mesh.position.set(node.position[0], node.position[1], node.position[2]) mesh.rotation.y = node.rotation @@ -524,8 +531,7 @@ export function getRoofSegmentBrushes( // when a group exists but covers no triangles (can happen after mergeVertices) geo.groups = geo.groups.filter((g) => g.count > 0) if (geo.groups.length === 0) return null - geo.computeBoundsTree = computeBoundsTree - geo.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(geo) const brush = new Brush(geo, dummyMats) brush.updateMatrixWorld() return brush diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx similarity index 96% rename from packages/core/src/systems/slab/slab-system.tsx rename to packages/viewer/src/systems/slab/slab-system.tsx index 16920d3a..d9f02df9 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -1,9 +1,12 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + getRenderableSlabPolygon, + sceneRegistry, + type SlabNode, + useScene, +} from '@pascal-app/core' import * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { getRenderableSlabPolygon } from '../../lib/slab-polygon' -import type { AnyNodeId, SlabNode } from '../../schema' -import useScene from '../../store/use-scene' function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx similarity index 98% rename from packages/core/src/systems/stair/stair-system.tsx rename to packages/viewer/src/systems/stair/stair-system.tsx index 77e40029..6f1e96d5 100644 --- a/packages/core/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -1,13 +1,18 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNode, + type AnyNodeId, + resolveLevelId, + sceneRegistry, + spatialGridManager, + type StairNode, + type StairSegmentNode, + syncAutoStairOpenings, + useScene, +} from '@pascal-app/core' import { useEffect, useRef } from 'react' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' -import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' -import useScene from '../../store/use-scene' -import { syncAutoStairOpenings } from './stair-opening-sync' const pendingStairUpdates = new Set() const MAX_STAIRS_PER_FRAME = 2 diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 5edccfea..fb1b4e99 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -1,5 +1,4 @@ import { - baseMaterial, getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, getWallSurfaceMaterialSignature, @@ -10,7 +9,7 @@ import { import { Color, type Material } from 'three' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' import { MeshStandardNodeMaterial } from 'three/webgpu' -import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' +import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../lib/materials' const DEFAULT_WALL_COLOR = '#f2f0ed' diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx similarity index 96% rename from packages/core/src/systems/wall/wall-system.tsx rename to packages/viewer/src/systems/wall/wall-system.tsx index dc2bf8a2..98c98467 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -2,21 +2,29 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' -import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import type { AnyNode, AnyNodeId, DoorNode, WallNode, WindowNode } from '../../schema' -import useScene from '../../store/use-scene' -import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' -import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { calculateLevelMiters, + type AnyNode, + type AnyNodeId, + type DoorNode, getAdjacentWallIds, + DEFAULT_WALL_HEIGHT, + getWallCurveFrameAt, getWallMiterBoundaryPoints, + getWallPlanFootprint, + getWallSurfacePolygon, + getWallThickness, + isCurvedWall, type Point2D, pointToKey, + resolveLevelId, + sceneRegistry, + spatialGridManager, + useScene, + type WallNode, type WallMiterData, -} from './wall-mitering' + type WindowNode, +} from '@pascal-app/core' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() @@ -24,6 +32,11 @@ const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 const WALL_FACE_NORMAL_Y_EPSILON = 0.6 const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003 +function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { + ;(geometry as any).computeBoundsTree = computeBoundsTree + ;(geometry as any).computeBoundsTree({ maxLeafSize: 10 }) +} + type WallBoundaryEdgeTag = 'front' | 'back' | 'base' type TaggedWallBoundaryEdge = { @@ -495,8 +508,7 @@ export function generateExtrudedWall( // Create wall brush from geometry // Pre-compute BVH with new API to avoid deprecation warning - geometry.computeBoundsTree = computeBoundsTree - geometry.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(geometry) const wallBrush = new Brush(geometry) wallBrush.updateMatrixWorld() @@ -599,8 +611,7 @@ function collectCutoutBrushes( ) // Pre-compute BVH with new API to avoid deprecation warning - boxGeo.computeBoundsTree = computeBoundsTree - boxGeo.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(boxGeo) const brush = new Brush(boxGeo) brushes.push(brush) @@ -638,8 +649,7 @@ function createShapedOpeningCutoutBrush(opening: ShapedOpeningNode, wallThicknes }) geometry.translate(0, 0, -depth / 2) - geometry.computeBoundsTree = computeBoundsTree - geometry.computeBoundsTree({ maxLeafSize: 10 }) + computeGeometryBoundsTree(geometry) return new Brush(geometry) } diff --git a/packages/core/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx similarity index 96% rename from packages/core/src/systems/window/window-system.tsx rename to packages/viewer/src/systems/window/window-system.tsx index 65d68455..01a77f43 100644 --- a/packages/core/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,9 +1,12 @@ import { useFrame } from '@react-three/fiber' +import { + type AnyNodeId, + sceneRegistry, + useScene, + type WindowNode, +} from '@pascal-app/core' import * as THREE from 'three' -import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { baseMaterial, glassMaterial } from '../../materials' -import type { AnyNodeId, WindowNode } from '../../schema' -import useScene from '../../store/use-scene' +import { baseMaterial, glassMaterial } from '../../lib/materials' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })