From 8631622e607cc8766b49413cad41cac633888891 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Apr 2026 13:37:52 +0530 Subject: [PATCH] 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 + } - const type = isScan ? 'scan' : 'guide' + 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 { 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}`} -

+