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 deleted file mode 100644 index 9885d6fc..00000000 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +++ /dev/null @@ -1,113 +0,0 @@ -'use client' - -import type { Point2D, RoofNode, RoofSegmentNode } from '@pascal-app/core' -import { memo } from 'react' -import { toSvgX, toSvgY } from '../svg-paths' - -type FloorplanLineSegment = { - start: Point2D - end: Point2D -} - -type FloorplanRoofSegmentEntry = { - segment: RoofSegmentNode - points: string - ridgeLine: FloorplanLineSegment | null -} - -type FloorplanRoofEntry = { - roof: RoofNode - 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) { - if (roofEntries.length === 0) { - return null - } - - return ( - <> - {roofEntries.map(({ roof, segments }) => { - const roofSelected = selectedIdSet.has(roof.id) - const roofHighlighted = highlightedIdSet.has(roof.id) - const hasSelectedSegment = segments.some(({ segment }) => selectedIdSet.has(segment.id)) - const hasHighlightedSegment = segments.some(({ segment }) => - highlightedIdSet.has(segment.id), - ) - const isRoofActive = - roofSelected || roofHighlighted || hasSelectedSegment || hasHighlightedSegment - - return ( - - {segments.map(({ points, ridgeLine, segment }) => { - const isSegmentSelected = selectedIdSet.has(segment.id) - const isSegmentHighlighted = highlightedIdSet.has(segment.id) - const isSegmentActive = isSegmentSelected || isSegmentHighlighted - - return ( - - - {ridgeLine ? ( - - ) : null} - - ) - })} - - ) - })} - - ) -}) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 9ebecfc3..3715327d 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -96,7 +96,6 @@ import { type LinearMeasurementOverlay, } from '../editor-2d/renderers/floorplan-measurements-layer' import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer' -import { FloorplanRoofLayer } from '../editor-2d/renderers/floorplan-roof-layer' import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer' import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' @@ -4073,2894 +4072,6 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer( ) }) -const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ - canFocusGeometry, - canSelectGeometry, - canSelectSlabs, - canSelectCeilings, - ceilingPolygons, - highlightedIdSet, - hoveredCeilingId, - hoveredSlabId, - hoveredOpeningId, - hoveredWallId, - isDeleteMode, - onCeilingDoubleClick, - onCeilingHoverChange, - onCeilingSelect, - onSlabDoubleClick, - onSlabHoverChange, - onSlabSelect, - onOpeningDoubleClick, - onOpeningHoverChange, - onOpeningPointerDown, - onOpeningSelect, - onWallClick, - onWallDoubleClick, - onWallHoverChange, - openingsPolygons, - palette, - selectedIdSet, - slabSelectionHatchId, - slabPolygons, - wallPolygons, - wallSelectionHatchId, - unit, - metersPerUnit, - isGuideTraceVisible, -}: { - canFocusGeometry: boolean - canSelectSlabs: boolean - canSelectCeilings: boolean - canSelectGeometry: boolean - ceilingPolygons: CeilingPolygonEntry[] - highlightedIdSet: ReadonlySet - hoveredCeilingId: CeilingNode['id'] | null - hoveredSlabId: SlabNode['id'] | null - hoveredOpeningId: OpeningNode['id'] | null - isDeleteMode: boolean - onCeilingDoubleClick: (ceiling: CeilingNode) => void - onCeilingHoverChange: (ceilingId: CeilingNode['id'] | null) => void - onCeilingSelect: (ceilingId: CeilingNode['id'], event: ReactMouseEvent) => void - onSlabDoubleClick: (slab: SlabNode) => void - onSlabHoverChange: (slabId: SlabNode['id'] | null) => void - onSlabSelect: (slabId: SlabNode['id'], event: ReactMouseEvent) => void - onOpeningDoubleClick: (opening: OpeningNode) => void - onOpeningHoverChange: (openingId: OpeningNode['id'] | null) => void - onOpeningPointerDown: (openingId: OpeningNode['id'], event: ReactPointerEvent) => void - onOpeningSelect: (openingId: OpeningNode['id'], event: ReactMouseEvent) => void - hoveredWallId: WallNode['id'] | null - onWallClick: (wall: WallNode, event: ReactMouseEvent) => void - onWallDoubleClick: (wall: WallNode, event: ReactMouseEvent) => void - onWallHoverChange: (wallId: WallNode['id'] | null) => void - openingsPolygons: OpeningPolygonEntry[] - palette: FloorplanPalette - selectedIdSet: ReadonlySet - slabSelectionHatchId: string - slabPolygons: SlabPolygonEntry[] - wallPolygons: WallPolygonEntry[] - wallSelectionHatchId: string - unit: 'metric' | 'imperial' - metersPerUnit: number | null - isGuideTraceVisible: boolean -}) { - const selectedWallEntries = wallPolygons.filter(({ wall }) => selectedIdSet.has(wall.id)) - const wallMeasurements = - selectedIdSet.size === 1 && selectedWallEntries.length === 1 - ? getSelectedWallMeasurementOverlays( - selectedWallEntries[0]!, - wallPolygons, - unit, - metersPerUnit, - ) - : [] - - return ( - <> - {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 - const showSelectedSlabStyle = isSelected || isHighlighted - const slabBorderStroke = isDeleteHovered - ? palette.deleteStroke - : showSelectedSlabStyle - ? 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) { - const { area, centroid } = getSlabArea(visualPolygon, visualHoles) - if (area > 0) { - slabLabel = ( - - {formatArea(area, unit, metersPerUnit)} - - ) - } - } - - return ( - - - { - event.stopPropagation() - onSlabSelect(slab.id, event) - } - : undefined - } - onDoubleClick={ - canFocusGeometry - ? (event) => { - event.stopPropagation() - onSlabDoubleClick(slab) - } - : undefined - } - onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined} - onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined} - opacity={slabFillOpacity} - pointerEvents={canSelectSlabs ? undefined : 'none'} - stroke="none" - style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined} - /> - {isSelected && !isDeleteHovered ? ( - - ) : null} - - {slabLabel} - - ) - })} - - {ceilingPolygons.map(({ ceiling, path }) => { - const isSelected = selectedIdSet.has(ceiling.id) - const isHighlighted = highlightedIdSet.has(ceiling.id) - const isDeleteHovered = isDeleteMode && hoveredCeilingId === ceiling.id - const showSelectedCeilingStyle = isSelected || isHighlighted - const ceilingBorderStroke = isDeleteHovered - ? palette.deleteStroke - : showSelectedCeilingStyle - ? 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 ( - - { - event.stopPropagation() - onCeilingSelect(ceiling.id, event) - } - : undefined - } - onDoubleClick={ - canFocusGeometry - ? (event) => { - event.stopPropagation() - onCeilingDoubleClick(ceiling) - } - : undefined - } - onPointerEnter={ - canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined - } - onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined} - opacity={ceilingFillOpacity} - pointerEvents={canSelectCeilings ? undefined : 'none'} - stroke="none" - style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined} - /> - {isSelected && !isDeleteHovered ? ( - - ) : null} - - - ) - })} - - {wallPolygons.map(({ wall, polygon, points }) => { - const isSelected = selectedIdSet.has(wall.id) - const isHighlighted = highlightedIdSet.has(wall.id) - const isHovered = canSelectGeometry && hoveredWallId === wall.id - const isDeleteHovered = isDeleteMode && isHovered - const showSelectedWallChrome = isSelected || isHighlighted - const wallStroke = isDeleteHovered - ? palette.deleteStroke - : showSelectedWallChrome - ? palette.selectedStroke - : palette.wallStroke - - return ( - onWallHoverChange(wall.id) : undefined} - onPointerLeave={canSelectGeometry ? () => onWallHoverChange(null) : undefined} - > - {canSelectGeometry && ( - { - event.stopPropagation() - onWallClick(wall, event) - }} - onDoubleClick={(event) => { - event.stopPropagation() - onWallDoubleClick(wall, event) - }} - pointerEvents="stroke" - stroke="transparent" - strokeLinecap="round" - strokeWidth={FLOORPLAN_WALL_HIT_STROKE_WIDTH} - style={{ cursor: EDITOR_CURSOR }} - vectorEffect="non-scaling-stroke" - x1={toSvgX(wall.start[0])} - x2={toSvgX(wall.end[0])} - y1={toSvgY(wall.start[1])} - y2={toSvgY(wall.end[1])} - /> - )} - { - event.stopPropagation() - onWallClick(wall, event) - } - : undefined - } - onDoubleClick={ - canSelectGeometry - ? (event) => { - event.stopPropagation() - onWallDoubleClick(wall, event) - } - : undefined - } - points={points} - stroke={wallStroke} - strokeOpacity={1} - strokeWidth={ - showSelectedWallChrome - ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH - : FLOORPLAN_WALL_STROKE_WIDTH - } - style={{ cursor: EDITOR_CURSOR }} - vectorEffect="non-scaling-stroke" - /> - {isSelected && !isDeleteHovered ? ( - - ) : null} - - ) - })} - - {openingsPolygons.map(({ opening, polygon, points }) => { - const isSelected = selectedIdSet.has(opening.id) - const isSelectionHighlighted = highlightedIdSet.has(opening.id) - const isHovered = canSelectGeometry && hoveredOpeningId === opening.id - const isDeleteHovered = isDeleteMode && isHovered - const centerLine = getOpeningCenterLine(polygon) - - if (opening.type === 'window') { - if (polygon.length < 4) return null - if (!centerLine) return null - const [p1, p2, p3, p4] = polygon - const tangentDx = p2!.x - p1!.x - const tangentDy = p2!.y - p1!.y - const tangentLength = Math.hypot(tangentDx, tangentDy) - const normalDx = p4!.x - p1!.x - const normalDy = p4!.y - p1!.y - const normalLength = Math.hypot(normalDx, normalDy) - - if (tangentLength < 1e-6 || normalLength < 1e-6) return null - - const tangentX = tangentDx / tangentLength - const tangentY = tangentDy / tangentLength - const normalX = normalDx / normalLength - const normalY = normalDy / normalLength - const tangentInset = Math.min(tangentLength * 0.08, 0.12) - const normalInset = Math.min(normalLength * 0.22, 0.07) - const insetInnerStart = { - x: p1!.x + tangentX * tangentInset + normalX * normalInset, - y: p1!.y + tangentY * tangentInset + normalY * normalInset, - } - const insetInnerEnd = { - x: p2!.x - tangentX * tangentInset + normalX * normalInset, - y: p2!.y - tangentY * tangentInset + normalY * normalInset, - } - const insetOuterEnd = { - x: p3!.x - tangentX * tangentInset - normalX * normalInset, - y: p3!.y - tangentY * tangentInset - normalY * normalInset, - } - const insetOuterStart = { - x: p4!.x + tangentX * tangentInset - normalX * normalInset, - y: p4!.y + tangentY * tangentInset - normalY * normalInset, - } - const centerStart = { - x: (insetInnerStart.x + insetOuterStart.x) / 2, - y: (insetInnerStart.y + insetOuterStart.y) / 2, - } - const centerEnd = { - x: (insetInnerEnd.x + insetOuterEnd.x) / 2, - y: (insetInnerEnd.y + insetOuterEnd.y) / 2, - } - const symbolStroke = - isSelected || isSelectionHighlighted ? '#f97316' : 'rgba(31, 41, 55, 0.92)' - const symbolFill = 'rgba(255, 255, 255, 0.96)' - const symbolStrokeWidth = isSelected || isSelectionHighlighted ? '1.9' : '1.25' - const innerStrokeWidth = isSelected || isSelectionHighlighted ? '1.3' : '0.9' - 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 ( - { - 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 && ( - - )} - - - - {[0.25, 0.5, 0.75].map((ratio) => { - const topPoint = { - x: insetInnerStart.x + (insetInnerEnd.x - insetInnerStart.x) * ratio, - y: insetInnerStart.y + (insetInnerEnd.y - insetInnerStart.y) * ratio, - } - const bottomPoint = { - x: insetOuterStart.x + (insetOuterEnd.x - insetOuterStart.x) * ratio, - y: insetOuterStart.y + (insetOuterEnd.y - insetOuterStart.y) * ratio, - } - const midPoint = { - x: (topPoint.x + bottomPoint.x) / 2, - y: (topPoint.y + bottomPoint.y) / 2, - } - const mullionHalf = normalLength * 0.18 - - return ( - - ) - })} - {isSelected ? ( - <> - - - - - ) : null} - - ) - } - - if (opening.type === 'door') { - if (polygon.length < 4) return null - const [p1, p2, p3, p4] = polygon - const svgP1 = toSvgPoint(p1!) - const svgP2 = toSvgPoint(p2!) - const svgP3 = toSvgPoint(p3!) - 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 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 isPlanFlipped = isOpeningPlanFlipped(opening.rotation) - const baseHingesSide = opening.hingesSide ?? 'left' - const baseSwingDirection = opening.swingDirection ?? 'inward' - const hingesSide = isPlanFlipped ? getFlippedHingesSide(baseHingesSide) : baseHingesSide - const swingDirection = isPlanFlipped - ? getFlippedSwingDirection(baseSwingDirection) - : baseSwingDirection - const swingAngle = Math.max(0, Math.min(Math.PI / 2, opening.swingAngle ?? 0)) - const width = opening.width - const sweepFlag = - hingesSide === 'left' - ? swingDirection === 'inward' - ? 0 - : 1 - : swingDirection === 'inward' - ? 1 - : 0 - - const hx = cx - nx * (width / 2) * (hingesSide === 'left' ? 1 : -1) - const hy = cy - ny * (width / 2) * (hingesSide === 'left' ? 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 doorAccent = - isSelected || isSelectionHighlighted ? '#f97316' : 'rgba(100, 116, 139, 0.82)' - const doorStroke = isDeleteHovered ? palette.deleteStroke : doorAccent - const doorSoftStroke = - isSelected || isSelectionHighlighted - ? 'rgba(251, 146, 60, 0.62)' - : 'rgba(148, 163, 184, 0.58)' - const doorLeafFill = - isSelected || isSelectionHighlighted ? 'rgba(255, 247, 237, 0.98)' : '#ffffff' - const doorOpeningFill = - isSelected || isSelectionHighlighted ? 'rgba(255, 247, 237, 0.98)' : '#ffffff' - const doorSwingFill = - isSelected || isSelectionHighlighted - ? 'rgba(251, 146, 60, 0.08)' - : 'rgba(148, 163, 184, 0.08)' - const doorCubeStroke = doorStroke - const hingeTangentSign = hingesSide === 'left' ? 1 : -1 - const hingeCubeCenter = { - x: hx + nx * hingeTangentSign * doorCubeInset, - y: hy + ny * hingeTangentSign * doorCubeInset, - } - const strikeCubeCenter = { - x: ox2 - nx * hingeTangentSign * doorCubeInset, - y: oy2 - ny * hingeTangentSign * doorCubeInset, - } - 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 closedLeafVector = { - x: arcEnd.x - leafStart.x, - y: arcEnd.y - leafStart.y, - } - const openAngle = swingAngle * swingSign * hingeTangentSign - const openCos = Math.cos(openAngle) - const openSin = Math.sin(openAngle) - const leafEnd = { - x: leafStart.x + closedLeafVector.x * openCos - closedLeafVector.y * openSin, - y: leafStart.y + closedLeafVector.x * openSin + closedLeafVector.y * openCos, - } - const doorBackgroundPointList = [ - { - 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, - }, - ] - const doorBackgroundPoints = doorBackgroundPointList - .map((point) => `${point.x},${point.y}`) - .join(' ') - const openingPlanPath = - opening.openingKind === 'opening' && opening.openingShape === 'rounded' - ? (() => { - const [a, b, c, d] = doorBackgroundPointList - if (!(a && b && c && d)) return null - - const tangentRadius = Math.min(width * 0.14, doorCubeSize * 1.6) - const depthRadius = Math.min( - Math.hypot(svgP4.x - svgP1.x, svgP4.y - svgP1.y) * 0.42, - doorCubeSize, - ) - const radius = Math.min(tangentRadius, depthRadius) - const offset = (from: Point2D, to: Point2D, distance: number) => { - const dx = to.x - from.x - const dy = to.y - from.y - const length = Math.hypot(dx, dy) - if (length < 1e-6) return from - return { - x: from.x + (dx / length) * Math.min(distance, length / 2), - y: from.y + (dy / length) * Math.min(distance, length / 2), - } - } - - const aToB = offset(a, b, radius) - const bToA = offset(b, a, radius) - const bToC = offset(b, c, radius) - const cToB = offset(c, b, radius) - const cToD = offset(c, d, radius) - const dToC = offset(d, c, radius) - const dToA = offset(d, a, radius) - const aToD = offset(a, d, radius) - - return [ - `M ${aToB.x} ${aToB.y}`, - `L ${bToA.x} ${bToA.y}`, - `Q ${b.x} ${b.y} ${bToC.x} ${bToC.y}`, - `L ${cToB.x} ${cToB.y}`, - `Q ${c.x} ${c.y} ${cToD.x} ${cToD.y}`, - `L ${dToC.x} ${dToC.y}`, - `Q ${d.x} ${d.y} ${dToA.x} ${dToA.y}`, - `L ${aToD.x} ${aToD.y}`, - `Q ${a.x} ${a.y} ${aToB.x} ${aToB.y}`, - 'Z', - ].join(' ') - })() - : null - const archPlanPath = - opening.openingKind === 'opening' && opening.openingShape === 'arch' - ? (() => { - const centerStart = { - x: (svgP1.x + svgP4.x) / 2, - y: (svgP1.y + svgP4.y) / 2, - } - const centerEnd = { - x: (svgP2.x + svgP3.x) / 2, - y: (svgP2.y + svgP3.y) / 2, - } - const midpoint = { - x: (centerStart.x + centerEnd.x) / 2, - y: (centerStart.y + centerEnd.y) / 2, - } - const bow = Math.min(width * 0.18, doorCubeSize * 1.8) - return `M ${centerStart.x} ${centerStart.y} Q ${midpoint.x + px * bow} ${ - midpoint.y + py * bow - } ${centerEnd.x} ${centerEnd.y}` - })() - : null - const leafPolygonPoints = [ - { - x: leafStart.x - nx * leafHalfThickness, - y: leafStart.y - ny * leafHalfThickness, - }, - { - x: leafEnd.x - nx * leafHalfThickness, - y: leafEnd.y - ny * leafHalfThickness, - }, - { - x: leafEnd.x + nx * leafHalfThickness, - y: leafEnd.y + ny * leafHalfThickness, - }, - { - x: leafStart.x + nx * leafHalfThickness, - y: leafStart.y + ny * leafHalfThickness, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - const swingSweepPath = - swingRadius > 1e-6 - ? `M ${leafStart.x} ${leafStart.y} L ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y} Z` - : null - const jambTickSize = doorCubeSize * 0.82 - const hingeMarkerRadius = Math.min(Math.max(doorCubeSize * 0.22, 0.018), 0.034) - const strikeTickStart = { - x: strikeCubeCenter.x - px * swingSign * jambTickSize * 0.5, - y: strikeCubeCenter.y - py * swingSign * jambTickSize * 0.5, - } - const strikeTickEnd = { - x: strikeCubeCenter.x + px * swingSign * jambTickSize * 0.5, - y: strikeCubeCenter.y + py * swingSign * jambTickSize * 0.5, - } - const closedLeafHintPoints = [ - { - x: leafStart.x - nx * leafHalfThickness * 0.7, - y: leafStart.y - ny * leafHalfThickness * 0.7, - }, - { - x: arcEnd.x - nx * leafHalfThickness * 0.7, - y: arcEnd.y - ny * leafHalfThickness * 0.7, - }, - { - x: arcEnd.x + nx * leafHalfThickness * 0.7, - y: arcEnd.y + ny * leafHalfThickness * 0.7, - }, - { - x: leafStart.x + nx * leafHalfThickness * 0.7, - y: leafStart.y + ny * leafHalfThickness * 0.7, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - const openingCenterLineStart = { - x: (svgP1.x + svgP4.x) / 2, - y: (svgP1.y + svgP4.y) / 2, - } - const openingCenterLineEnd = { - x: (svgP2.x + svgP3.x) / 2, - y: (svgP2.y + svgP3.y) / 2, - } - const isFoldingDoor = opening.doorType === 'folding' - const foldingPanelCount = opening.leafCount === 2 ? 2 : 4 - const foldingAmount = Math.max(0, Math.min(1, opening.operationState ?? 0)) - const foldingSpan = Math.max(1e-6, Math.hypot(svgP2.x - svgP1.x, svgP2.y - svgP1.y)) - const foldingPanelLength = foldingSpan / foldingPanelCount - const foldingAngle = Math.PI * 0.44 * foldingAmount - const foldingPoints = isFoldingDoor - ? Array.from({ length: foldingPanelCount + 1 }).reduce( - (points, _, index) => { - if (index === 0) return [{ x: svgP1.x, y: svgP1.y }] - - const previous = points[index - 1]! - const direction = (index - 1) % 2 === 0 ? -1 : 1 - const angle = direction * foldingAngle - const along = Math.cos(angle) * foldingPanelLength - const out = Math.sin(angle) * foldingPanelLength * swingSign - points.push({ - x: previous.x + nx * along + px * out, - y: previous.y + ny * along + py * out, - }) - return points - }, - [], - ) - : [] - const foldingPath = - foldingPoints.length > 0 - ? foldingPoints - .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) - .join(' ') - : null - const isPocketDoor = opening.doorType === 'pocket' - const pocketAmount = Math.max(0, Math.min(1, opening.operationState ?? 0)) - const pocketSign = opening.slideDirection === 'right' ? 1 : -1 - const pocketShift = pocketSign * foldingSpan * pocketAmount - const pocketTrackStart = - pocketSign > 0 - ? svgP1 - : { x: svgP1.x - nx * foldingSpan, y: svgP1.y - ny * foldingSpan } - const pocketTrackEnd = - pocketSign > 0 - ? { x: svgP2.x + nx * foldingSpan, y: svgP2.y + ny * foldingSpan } - : svgP2 - const pocketLeafStart = { - x: svgP1.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5, - y: svgP1.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5, - } - const pocketLeafEnd = { - x: svgP2.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5, - y: svgP2.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5, - } - const pocketLeafPoints = [ - { - x: pocketLeafStart.x - px * leafHalfThickness, - y: pocketLeafStart.y - py * leafHalfThickness, - }, - { - x: pocketLeafEnd.x - px * leafHalfThickness, - y: pocketLeafEnd.y - py * leafHalfThickness, - }, - { - x: pocketLeafEnd.x + px * leafHalfThickness, - y: pocketLeafEnd.y + py * leafHalfThickness, - }, - { - x: pocketLeafStart.x + px * leafHalfThickness, - y: pocketLeafStart.y + py * leafHalfThickness, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - const isBarnDoor = opening.doorType === 'barn' - const barnLeafStart = { - x: pocketLeafStart.x + px * swingSign * doorCubeSize * 0.75, - y: pocketLeafStart.y + py * swingSign * doorCubeSize * 0.75, - } - const barnLeafEnd = { - x: pocketLeafEnd.x + px * swingSign * doorCubeSize * 0.75, - y: pocketLeafEnd.y + py * swingSign * doorCubeSize * 0.75, - } - const barnLeafPoints = [ - { - x: barnLeafStart.x - px * leafHalfThickness, - y: barnLeafStart.y - py * leafHalfThickness, - }, - { - x: barnLeafEnd.x - px * leafHalfThickness, - y: barnLeafEnd.y - py * leafHalfThickness, - }, - { - x: barnLeafEnd.x + px * leafHalfThickness, - y: barnLeafEnd.y + py * leafHalfThickness, - }, - { - x: barnLeafStart.x + px * leafHalfThickness, - y: barnLeafStart.y + py * leafHalfThickness, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - const isSlidingDoor = opening.doorType === 'sliding' - const slidingPanelSpan = foldingSpan * 0.54 - const slidingActiveOnRight = opening.slideDirection !== 'right' - const slidingFixedSign = slidingActiveOnRight ? -1 : 1 - const slidingActiveSign = slidingActiveOnRight ? 1 : -1 - const slidingFixedCenter = slidingFixedSign * foldingSpan * 0.23 - const slidingActiveCenter = - slidingActiveSign * foldingSpan * 0.23 - - slidingActiveSign * foldingSpan * 0.44 * pocketAmount - const slidingPanelPoints = (centerOffset: number, faceOffset: number) => { - const start = { - x: - svgP1.x + - nx * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) + - px * swingSign * faceOffset, - y: - svgP1.y + - ny * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) + - py * swingSign * faceOffset, - } - const end = { - x: - svgP1.x + - nx * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) + - px * swingSign * faceOffset, - y: - svgP1.y + - ny * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) + - py * swingSign * faceOffset, - } - return [ - { x: start.x - px * leafHalfThickness, y: start.y - py * leafHalfThickness }, - { x: end.x - px * leafHalfThickness, y: end.y - py * leafHalfThickness }, - { x: end.x + px * leafHalfThickness, y: end.y + py * leafHalfThickness }, - { x: start.x + px * leafHalfThickness, y: start.y + py * leafHalfThickness }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' ') - } - const slidingFixedPoints = slidingPanelPoints(slidingFixedCenter, doorCubeSize * 0.34) - const slidingActivePoints = slidingPanelPoints(slidingActiveCenter, doorCubeSize * 0.68) - const isGarageSectionalDoor = opening.doorType === 'garage-sectional' - const isGarageRollupDoor = opening.doorType === 'garage-rollup' - const isGarageTiltupDoor = opening.doorType === 'garage-tiltup' - const garagePanelCount = Math.max(3, Math.min(12, opening.garagePanelCount ?? 4)) - const garagePanelLines = Array.from({ length: garagePanelCount - 1 }, (_, index) => { - const t = (index + 1) / garagePanelCount - return { - start: { - x: svgP1.x + (svgP2.x - svgP1.x) * t, - y: svgP1.y + (svgP2.y - svgP1.y) * t, - }, - end: { - x: svgP1.x + (svgP2.x - svgP1.x) * t + px * swingSign * doorCubeSize * 0.78, - y: svgP1.y + (svgP2.y - svgP1.y) * t + py * swingSign * doorCubeSize * 0.78, - }, - } - }) - const isDoubleSwingDoor = opening.doorType === 'double' || opening.doorType === 'french' - const doubleLeafPlans = isDoubleSwingDoor - ? ( - [ - { - key: 'left', - hingePoint: { x: cx - nx * (width / 2), y: cy - ny * (width / 2) }, - strikePoint: { x: cx, y: cy }, - }, - { - key: 'right', - hingePoint: { x: cx + nx * (width / 2), y: cy + ny * (width / 2) }, - strikePoint: { x: cx, y: cy }, - }, - ] as const - ).map(({ key, hingePoint, strikePoint }) => { - const tangentSign = key === 'left' ? 1 : -1 - const planHingeCubeCenter = { - x: hingePoint.x + nx * tangentSign * doorCubeInset, - y: hingePoint.y + ny * tangentSign * doorCubeInset, - } - const planStrikeCubeCenter = { - x: strikePoint.x - nx * tangentSign * doorCubeInset, - y: strikePoint.y - ny * tangentSign * doorCubeInset, - } - const planLeafStart = { - x: - planHingeCubeCenter.x + - px * swingSign * (doorCubeSize / 2) + - nx * tangentSign * (doorCubeSize / 2 + leafHalfThickness), - y: - planHingeCubeCenter.y + - py * swingSign * (doorCubeSize / 2) + - ny * tangentSign * (doorCubeSize / 2 + leafHalfThickness), - } - const planArcEnd = { - x: - planStrikeCubeCenter.x + - px * swingSign * (doorCubeSize / 2) - - nx * tangentSign * (doorCubeSize / 2), - y: - planStrikeCubeCenter.y + - py * swingSign * (doorCubeSize / 2) - - ny * tangentSign * (doorCubeSize / 2), - } - const planSwingRadius = Math.hypot( - planArcEnd.x - planLeafStart.x, - planArcEnd.y - planLeafStart.y, - ) - const planClosedLeafVector = { - x: planArcEnd.x - planLeafStart.x, - y: planArcEnd.y - planLeafStart.y, - } - const planOpenAngle = swingAngle * swingSign * tangentSign - const planOpenCos = Math.cos(planOpenAngle) - const planOpenSin = Math.sin(planOpenAngle) - const planLeafEnd = { - x: - planLeafStart.x + - planClosedLeafVector.x * planOpenCos - - planClosedLeafVector.y * planOpenSin, - y: - planLeafStart.y + - planClosedLeafVector.x * planOpenSin + - planClosedLeafVector.y * planOpenCos, - } - const planSweepFlag = - key === 'left' - ? swingDirection === 'inward' - ? 0 - : 1 - : swingDirection === 'inward' - ? 1 - : 0 - - return { - key, - hingeCubeCenter: planHingeCubeCenter, - strikeCubeCenter: planStrikeCubeCenter, - hingeMarkerX: planHingeCubeCenter.x, - hingeMarkerY: planHingeCubeCenter.y, - swingRadius: planSwingRadius, - sweepFlag: planSweepFlag, - arcEnd: planArcEnd, - leafEnd: planLeafEnd, - leafPolygonPoints: [ - { - x: planLeafStart.x - nx * leafHalfThickness, - y: planLeafStart.y - ny * leafHalfThickness, - }, - { - x: planLeafEnd.x - nx * leafHalfThickness, - y: planLeafEnd.y - ny * leafHalfThickness, - }, - { - x: planLeafEnd.x + nx * leafHalfThickness, - y: planLeafEnd.y + ny * leafHalfThickness, - }, - { - x: planLeafStart.x + nx * leafHalfThickness, - y: planLeafStart.y + ny * leafHalfThickness, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' '), - closedLeafHintPoints: [ - { - x: planLeafStart.x - nx * leafHalfThickness * 0.7, - y: planLeafStart.y - ny * leafHalfThickness * 0.7, - }, - { - x: planArcEnd.x - nx * leafHalfThickness * 0.7, - y: planArcEnd.y - ny * leafHalfThickness * 0.7, - }, - { - x: planArcEnd.x + nx * leafHalfThickness * 0.7, - y: planArcEnd.y + ny * leafHalfThickness * 0.7, - }, - { - x: planLeafStart.x + nx * leafHalfThickness * 0.7, - y: planLeafStart.y + ny * leafHalfThickness * 0.7, - }, - ] - .map((point) => `${point.x},${point.y}`) - .join(' '), - } - }) - : [] - - 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 && ( - - )} - {opening.openingKind === 'opening' ? ( - <> - {openingPlanPath ? ( - - ) : ( - - )} - - {archPlanPath && ( - - )} - - ) : ( - <> - - {isFoldingDoor ? ( - <> - - {foldingPath && ( - - )} - {foldingPoints.map((point, index) => ( - - ))} - - ) : isPocketDoor ? ( - <> - - - - - - ) : isBarnDoor ? ( - <> - - - - {[0.28, 0.72].map((ratio) => { - const wheel = { - x: barnLeafStart.x + (barnLeafEnd.x - barnLeafStart.x) * ratio, - y: barnLeafStart.y + (barnLeafEnd.y - barnLeafStart.y) * ratio, - } - return ( - - ) - })} - - ) : isSlidingDoor ? ( - <> - - - - - - ) : isGarageSectionalDoor || isGarageRollupDoor || isGarageTiltupDoor ? ( - <> - - - {isGarageRollupDoor ? ( - - ) : isGarageTiltupDoor ? ( - - ) : ( - garagePanelLines.map((line, index) => ( - - )) - )} - - ) : isDoubleSwingDoor ? ( - <> - {doubleLeafPlans.map((leaf) => - leaf.swingRadius > 1e-6 ? ( - - ) : null, - )} - {swingAngle > 0.03 && - doubleLeafPlans.map((leaf) => ( - - ))} - {doubleLeafPlans.map((leaf) => ( - - ))} - {doubleLeafPlans.map((leaf) => ( - - ))} - {doubleLeafPlans.map((leaf) => ( - - ))} - - ) : ( - <> - {swingSweepPath && ( - - )} - {swingAngle > 0.03 && ( - - )} - {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( - - ))} - - - - - - )} - - )} - {isSelected ? ( - <> - - - - - ) : null} - - ) - } - - return null - })} - - - - ) -}) - -const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ - canFocusGeometry, - canSelectGeometry, - fenceEntries, - highlightedIdSet, - hoveredFenceId, - isDeleteMode, - onFenceDoubleClick, - onFenceHoverChange, - onFenceHoverEnter, - onFencePointerDown, - onFenceSelect, - palette, - selectedIdSet, -}: { - canFocusGeometry: boolean - canSelectGeometry: boolean - fenceEntries: FloorplanFenceEntry[] - highlightedIdSet: ReadonlySet - hoveredFenceId: FenceNode['id'] | null - isDeleteMode: boolean - onFenceDoubleClick: (fence: FenceNode, event: ReactMouseEvent) => void - onFenceHoverChange: (fenceId: FenceNode['id'] | null) => void - onFenceHoverEnter: (fenceId: FenceNode['id']) => void - onFencePointerDown: (fenceId: FenceNode['id'], event: ReactPointerEvent) => void - onFenceSelect: (fence: FenceNode, event: ReactMouseEvent) => void - palette: FloorplanPalette - selectedIdSet: ReadonlySet -}) { - if (fenceEntries.length === 0) { - return null - } - - return ( - <> - {fenceEntries.map(({ fence, markerFrames, path }) => { - const isSelected = selectedIdSet.has(fence.id) - const isHighlighted = highlightedIdSet.has(fence.id) - const isHovered = hoveredFenceId === fence.id - const isDeleteHovered = isDeleteMode && isHovered - const isActive = isSelected || isHighlighted - const showInteractiveChrome = isActive || isHovered - const fenceStroke = isDeleteHovered - ? palette.deleteStroke - : isActive - ? palette.selectedStroke - : isHovered - ? palette.wallHoverStroke - : '#111827' - const fenceAccent = fenceStroke - const fenceUnderlayStroke = isDeleteHovered ? palette.surface : 'rgba(255, 255, 255, 0.98)' - const fenceGlowStroke = isDeleteHovered - ? palette.deleteStroke - : isActive - ? palette.selectedStroke - : palette.wallHoverStroke - const fenceGlowOpacity = isDeleteHovered ? 0.18 : isActive ? 0.22 : isHovered ? 0.14 : 0 - const fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2' - const fenceStrokeWidth = isActive ? '2.6' : isHovered ? '2.35' : '2.05' - const showFenceInfill = fence.showInfill ?? true - const visibleMarkerFrames = showFenceInfill - ? markerFrames - : markerFrames.filter( - (_, markerIndex) => markerIndex === 0 || markerIndex === markerFrames.length - 1, - ) - const privacyMarkerWidth = clamp(fence.postSize * 0.58, 0.038, 0.068) - const privacyMarkerHeight = clamp( - Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), - 0.1, - 0.17, - ) - const railMarkerRadius = clamp(fence.postSize * 0.52, 0.048, 0.078) - const slatMarkerHalf = clamp(fence.postSize * 0.42, 0.03, 0.055) - const markerStrokeWidth = isActive ? '1.65' : '1.35' - - return ( - onFenceHoverEnter(fence.id) : undefined} - onPointerLeave={canSelectGeometry ? () => onFenceHoverChange(null) : undefined} - > - {showInteractiveChrome ? ( - - ) : null} - - - {visibleMarkerFrames.map(({ angleDeg, point }, markerIndex) => { - const svgPoint = toSvgPoint(point) - - if (fence.style === 'privacy') { - return ( - - - - - ) - } - - if (fence.style === 'rail') { - return ( - - - - - - ) - } - - return ( - - - - - - - ) - })} - { - event.stopPropagation() - onFenceSelect(fence, event) - } - : undefined - } - onDoubleClick={ - canFocusGeometry - ? (event) => { - event.stopPropagation() - onFenceDoubleClick(fence, event) - } - : undefined - } - onPointerDown={ - canSelectGeometry && isSelected - ? (event) => { - if (event.button === 0) { - onFencePointerDown(fence.id, event) - } - } - : undefined - } - pointerEvents={canSelectGeometry ? 'stroke' : 'none'} - stroke="transparent" - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH} - style={canSelectGeometry ? { cursor: EDITOR_CURSOR } : undefined} - vectorEffect="non-scaling-stroke" - /> - - ) - })} - - ) -}) - -const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ - canSelectElevators, - elevatorEntries, - highlightedIdSet, - hoveredElevatorId, - isDeleteMode, - onElevatorHoverChange, - onElevatorHoverEnter, - onElevatorPointerDown, - onElevatorResizePointerDown, - onElevatorResizePointerMove, - onElevatorResizePointerUp, - onElevatorSelect, - palette, - selectedIdSet, - wallSelectionHatchId, -}: { - canSelectElevators: boolean - elevatorEntries: FloorplanElevatorEntry[] - highlightedIdSet: ReadonlySet - hoveredElevatorId: ElevatorNode['id'] | null - isDeleteMode: boolean - onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void - onElevatorHoverEnter: (elevatorId: ElevatorNode['id']) => void - onElevatorPointerDown: ( - elevatorId: ElevatorNode['id'], - event: ReactPointerEvent, - ) => void - onElevatorResizePointerDown: ( - entry: FloorplanElevatorEntry, - handle: ElevatorResizeHandle, - event: ReactPointerEvent, - ) => void - onElevatorResizePointerMove: (event: ReactPointerEvent) => void - onElevatorResizePointerUp: (event: ReactPointerEvent) => void - onElevatorSelect: (elevator: ElevatorNode, event: ReactMouseEvent) => void - palette: FloorplanPalette - selectedIdSet: ReadonlySet - wallSelectionHatchId: string -}) { - if (elevatorEntries.length === 0) { - return null - } - - return ( - - {elevatorEntries.map((entry) => { - const { elevator } = entry - const isSelected = selectedIdSet.has(elevator.id) - const isHighlighted = highlightedIdSet.has(elevator.id) - const isHovered = hoveredElevatorId === elevator.id - const isDeleteHovered = isDeleteMode && isHovered - const isActive = isSelected || isHighlighted - const showChrome = isActive || isHovered - const isGlassShaft = elevator.shaftStyle === 'glass' - const shaftShellFill = isDeleteHovered - ? palette.deleteFill - : isActive - ? `url(#${wallSelectionHatchId})` - : isGlassShaft - ? '#dff6ff' - : '#e5e7eb' - const shaftClearFill = isGlassShaft ? '#ecfeff' : '#f8fafc' - const shaftShellOpacity = isDeleteHovered ? 0.38 : isActive ? 0.9 : isHovered ? 0.86 : 0.76 - const stroke = isDeleteHovered - ? palette.deleteStroke - : isActive - ? palette.selectedStroke - : isHovered - ? palette.wallHoverStroke - : isGlassShaft - ? '#0891b2' - : '#475569' - const doorStroke = isDeleteHovered - ? palette.deleteStroke - : isActive - ? palette.selectedStroke - : '#0369a1' - const centerX = toSvgX(entry.center.x) - const centerY = toSvgY(entry.center.y) - const rotationDeg = (-entry.rotation * 180) / Math.PI - const shaftWidth = entry.outerHalfWidth * 2 - const shaftDepth = entry.outerHalfDepth * 2 - const shaftClearX = -entry.shaftWidth / 2 - const shaftClearY = -entry.shaftDepth / 2 - const cabX = -entry.cabWidth / 2 - const cabY = entry.cabCenterLocalY - entry.cabDepth / 2 - const frontLocalY = -entry.outerHalfDepth - const doorHalfWidth = entry.doorWidth / 2 - const doorTrackY = frontLocalY - 0.075 - const callStationX = Math.min(entry.outerHalfWidth - 0.12, doorHalfWidth + 0.2) - const callStationY = frontLocalY - 0.16 - const cabFill = entry.isCarOnLevel - ? '#dcfce7' - : entry.isTargetLevel || entry.isQueuedLevel - ? '#e0f2fe' - : '#f8fafc' - const cabStroke = entry.isCarOnLevel - ? '#16a34a' - : entry.isTargetLevel || entry.isQueuedLevel - ? '#0ea5e9' - : '#64748b' - const showCarMarker = entry.isCarOnLevel || entry.isTargetLevel || entry.isQueuedLevel - const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff' - const carStroke = entry.isCarOnLevel ? '#15803d' : '#0ea5e9' - const resizeHandles = [ - { - cursor: 'ew-resize', - handle: 'width-negative' as const, - localX: -entry.outerHalfWidth, - localY: 0, - }, - { - cursor: 'ew-resize', - handle: 'width-positive' as const, - localX: entry.outerHalfWidth, - localY: 0, - }, - { - cursor: 'ns-resize', - handle: 'depth-negative' as const, - localX: 0, - localY: -entry.outerHalfDepth, - }, - { - cursor: 'ns-resize', - handle: 'depth-positive' as const, - localX: 0, - localY: entry.outerHalfDepth, - }, - ].map((handle) => { - const [offsetX, offsetY] = rotatePlanVector(handle.localX, handle.localY, entry.rotation) - return { - ...handle, - x: entry.center.x + offsetX, - y: entry.center.y + offsetY, - } - }) - const rangeStep = 0.18 - const rangeHeight = Math.max(0, (entry.servedLevels.length - 1) * rangeStep) - const [rangeOffsetX, rangeOffsetY] = rotatePlanVector( - entry.outerHalfWidth + 0.38, - 0, - entry.rotation, - ) - const rangeX = entry.center.x + rangeOffsetX - const rangeTopY = entry.center.y + rangeOffsetY - rangeHeight / 2 - const rangeBottomY = entry.center.y + rangeOffsetY + rangeHeight / 2 - - return ( - onElevatorHoverEnter(elevator.id) : undefined - } - onPointerLeave={canSelectElevators ? () => onElevatorHoverChange(null) : undefined} - > - {showChrome ? ( - - ) : null} - - - - - - - - - {entry.doorStyle === 'center-opening' ? ( - <> - - - - ) : ( - - )} - - - {showCarMarker ? ( - - ) : null} - - { - event.stopPropagation() - onElevatorSelect(elevator, event) - } - : undefined - } - onPointerDown={ - canSelectElevators && isSelected - ? (event) => { - if (event.button === 0) { - onElevatorPointerDown(elevator.id, event) - } - } - : undefined - } - points={entry.points} - pointerEvents={canSelectElevators ? 'all' : 'none'} - style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined} - > - {elevator.name || 'Elevator'} - - {isSelected && entry.servedLevels.length > 1 ? ( - - - {entry.servedLevels.map((level, index) => { - const y = rangeBottomY - index * rangeStep - const isUnavailable = level.isDisabled || level.isServiceOnly - const markerFill = level.isCurrent - ? '#22c55e' - : level.isTarget || level.isQueued - ? '#38bdf8' - : isUnavailable - ? '#94a3b8' - : '#ffffff' - const markerStroke = isUnavailable ? '#64748b' : '#0369a1' - - return ( - - - - {index + 1} - - - ) - })} - - ) : null} - {isSelected && canSelectElevators && !isDeleteMode - ? resizeHandles.map((handle) => ( - - onElevatorResizePointerDown(entry, handle.handle, event) - } - onPointerMove={onElevatorResizePointerMove} - onPointerUp={onElevatorResizePointerUp} - r={0.075} - stroke="#0284c7" - strokeWidth="1.7" - style={{ cursor: handle.cursor }} - vectorEffect="non-scaling-stroke" - /> - )) - : null} - - ) - })} - - ) -}) - -// 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. - -function FloorplanItemImage({ - url, - center, - rotation, - width, - depth, -}: { - url: string - center: Point2D - rotation: number - width: number - depth: number -}) { - const resolvedUrl = useResolvedAssetUrl(url) - if (!resolvedUrl) return null - // The PNG is captured with the modal's top-down camera (default up = +Y), - // so its pixel-right is world +X and pixel-up is world -Z. The plan SVG - // negates both axes (`toSvgX(v) = -v`, `toSvgY(v) = -v`), which together - // are a 180° rotation — so the captured image lands upside-down / - // mirrored when overlaid as-is. Bake that 180° into the image transform - // here; the panel / modal previews use the PNG directly and stay correct. - const rotationDeg = (-rotation * 180) / Math.PI + 180 - return ( - - - - ) -} - -const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ - canFocusItems, - canFocusSpawns, - canFocusStairs, - canSelectItems, - canSelectSpawns, - canSelectStairs, - highlightedIdSet, - hoveredItemId, - hoveredSpawnId, - hoveredStairId, - isDeleteMode, - isFurnishContextActive, - itemEntries, - onItemDoubleClick, - onItemHoverChange, - onItemHoverEnter, - onItemPointerDown, - onItemSelect, - onSpawnDoubleClick, - onSpawnHoverChange, - onSpawnHoverEnter, - onSpawnPointerDown, - onSpawnSelect, - onStairDoubleClick, - onStairHoverChange, - onStairHoverEnter, - onStairPointerDown, - onStairSelect, - palette, - selectedIdSet, - spawnEntries, - stairEntries, - unit, - wallSelectionHatchId, -}: { - canFocusItems: boolean - canFocusSpawns: boolean - canFocusStairs: boolean - canSelectItems: boolean - canSelectSpawns: boolean - canSelectStairs: boolean - highlightedIdSet: ReadonlySet - hoveredItemId: ItemNode['id'] | null - hoveredSpawnId: SpawnNode['id'] | null - hoveredStairId: StairNode['id'] | null - isDeleteMode: boolean - isFurnishContextActive: boolean - itemEntries: FloorplanItemEntry[] - onItemDoubleClick: (item: ItemNode, event: ReactMouseEvent) => void - onItemHoverChange: (itemId: ItemNode['id'] | null) => void - onItemHoverEnter: (itemId: ItemNode['id']) => void - onItemPointerDown: (itemId: ItemNode['id'], event: ReactPointerEvent) => void - onItemSelect: (itemId: ItemNode['id'], event: ReactMouseEvent) => void - onSpawnDoubleClick: (spawn: SpawnNode, event: ReactMouseEvent) => void - onSpawnHoverChange: (spawnId: SpawnNode['id'] | null) => void - onSpawnHoverEnter: (spawnId: SpawnNode['id']) => void - onSpawnPointerDown: (spawnId: SpawnNode['id'], event: ReactPointerEvent) => void - onSpawnSelect: (spawnId: SpawnNode['id'], event: ReactMouseEvent) => void - onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent) => void - onStairHoverChange: (stairId: StairNode['id'] | null) => void - onStairHoverEnter: (stairId: StairNode['id']) => void - onStairPointerDown: (stairId: StairNode['id'], event: ReactPointerEvent) => void - onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent) => void - palette: FloorplanPalette - selectedIdSet: ReadonlySet - spawnEntries: FloorplanSpawnEntry[] - stairEntries: FloorplanStairEntry[] - unit: 'metric' | 'imperial' - wallSelectionHatchId: string -}) { - if (itemEntries.length === 0 && stairEntries.length === 0 && spawnEntries.length === 0) { - return null - } - - const itemNodes = itemEntries.map((itemEntry) => { - const { item, points, polygon, center, rotation, width, depth } = itemEntry - const itemDimensionMeasurements = getItemDimensionMeasurementOverlays(itemEntry, unit) - const isSelected = selectedIdSet.has(item.id) - const isHighlighted = highlightedIdSet.has(item.id) - const isHovered = hoveredItemId === item.id - const isDeleteHovered = isDeleteMode && isHovered - const isSelectionActive = isSelected || isHighlighted - const showHighlight = isDeleteHovered || (isHovered && !isSelectionActive) - const stroke = isDeleteHovered - ? palette.deleteStroke - : isSelectionActive - ? palette.selectedStroke - : palette.wallStroke - const highlightStroke = isDeleteHovered - ? palette.deleteStroke - : isSelectionActive - ? palette.selectedStroke - : palette.wallHoverStroke - const fill = isDeleteHovered ? palette.deleteFill : palette.openingFill - const crossStrokeOpacity = isDeleteHovered - ? 0.76 - : isSelectionActive - ? 0.72 - : isHovered - ? 0.58 - : 0.52 - const floorPlanUrl = item.asset.floorPlanUrl - const diagonalAStart = polygon[0] - const diagonalAEnd = polygon[2] - const diagonalBStart = polygon[1] - const diagonalBEnd = polygon[3] - - return ( - { - event.stopPropagation() - onItemSelect(item.id, event) - } - : undefined - } - onDoubleClick={ - canFocusItems - ? (event) => { - event.stopPropagation() - onItemDoubleClick(item, event) - } - : undefined - } - onPointerDown={ - canFocusItems && isSelected - ? (event) => { - if (event.button === 0) { - onItemPointerDown(item.id, event) - } - } - : undefined - } - onPointerEnter={canSelectItems ? () => onItemHoverEnter(item.id) : undefined} - onPointerLeave={canSelectItems ? () => onItemHoverChange(null) : undefined} - pointerEvents={canSelectItems ? undefined : 'none'} - style={canSelectItems ? { cursor: EDITOR_CURSOR } : undefined} - > - {item.name || item.asset.name} - - - - {floorPlanUrl ? ( - - ) : ( - <> - {diagonalAStart && diagonalAEnd && ( - - )} - {diagonalBStart && diagonalBEnd && ( - - )} - - )} - {isSelected && !isDeleteHovered ? ( - - ) : null} - {itemDimensionMeasurements.length > 0 ? ( - - ) : null} - - ) - }) - - const spawnNodes = spawnEntries.map(({ spawn, position, rotation }) => { - const isSelected = selectedIdSet.has(spawn.id) - const isHighlighted = highlightedIdSet.has(spawn.id) - const isHovered = hoveredSpawnId === spawn.id - const isDeleteHovered = isDeleteMode && isHovered - const isSelectionActive = isSelected || isHighlighted - const showHighlight = isDeleteHovered || (isHovered && !isSelectionActive) - const stroke = isDeleteHovered - ? palette.deleteStroke - : isSelectionActive - ? palette.selectedStroke - : '#16a34a' - const fill = isDeleteHovered ? palette.deleteFill : '#22c55e' - const rotationDeg = (-rotation * 180) / Math.PI - - return ( - { - event.stopPropagation() - onSpawnSelect(spawn.id, event) - } - : undefined - } - onDoubleClick={ - canFocusSpawns - ? (event) => { - event.stopPropagation() - onSpawnDoubleClick(spawn, event) - } - : undefined - } - onPointerDown={ - canFocusSpawns && isSelected - ? (event) => { - if (event.button === 0) { - onSpawnPointerDown(spawn.id, event) - } - } - : undefined - } - onPointerEnter={canSelectSpawns ? () => onSpawnHoverEnter(spawn.id) : undefined} - onPointerLeave={canSelectSpawns ? () => onSpawnHoverChange(null) : undefined} - pointerEvents={canSelectSpawns ? undefined : 'none'} - style={canSelectSpawns ? { cursor: EDITOR_CURSOR } : undefined} - transform={`translate(${toSvgX(position.x)} ${toSvgY(position.y)}) rotate(${rotationDeg})`} - > - {spawn.name || 'Spawn Point'} - - - - - - - - ) - }) - - return ( - <> - {isFurnishContextActive ? ( - <> - - {itemNodes} - {spawnNodes} - - ) : ( - <> - {itemNodes} - {spawnNodes} - - - )} - - ) -}) - const FloorplanSiteLayer = memo(function FloorplanSiteLayer({ isEditing, sitePolygon, @@ -16911,59 +14022,6 @@ export function FloorplanPanel() { - - - - - - - - -