From 53393b7def5c7abdc8cb19b3a5c2309f1083e204 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 8 May 2026 12:54:59 +0530 Subject: [PATCH 1/9] Add procedural support column variants --- packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/column.ts | 582 +++++ packages/core/src/schema/nodes/fence.ts | 2 + .../src/components/editor/floorplan-panel.tsx | 58 +- .../src/components/ui/panels/column-panel.tsx | 579 +++-- .../src/components/ui/panels/fence-panel.tsx | 7 + .../renderers/column/column-renderer.tsx | 1921 ++++++++++++----- .../viewer/src/systems/fence/fence-system.tsx | 23 +- 8 files changed, 2452 insertions(+), 721 deletions(-) diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 0a16d9517..b088079a5 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -39,6 +39,7 @@ export { ColumnShaftDetail, ColumnShaftProfile, ColumnStyle, + ColumnSupportStyle, } from './nodes/column' export { DoorNode, DoorSegment } from './nodes/door' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index a7fa83a8e..0e19a8d6f 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -56,6 +56,20 @@ export const ColumnRingPlacement = z.enum(['ends', 'even', 'top', 'bottom']) export const ColumnCarvingPlacement = z.enum(['shaft', 'base', 'capital', 'all']) +export const ColumnSupportStyle = z.enum([ + 'vertical', + 'a-frame', + 'y-frame', + 'v-frame', + 'x-brace', + 'k-brace', + 'single-strut', + 'tripod', + 'trestle', + 'portal-frame', + 'box-frame', +]) + export type ColumnStyle = z.infer export type ColumnCrossSection = z.infer export type ColumnShaftProfile = z.infer @@ -65,6 +79,7 @@ export type ColumnBaseStyle = z.infer export type ColumnCapitalStyle = z.infer export type ColumnRingPlacement = z.infer export type ColumnCarvingPlacement = z.infer +export type ColumnSupportStyle = z.infer export const ColumnNode = BaseNode.extend({ id: objectId('column'), @@ -136,6 +151,12 @@ export const ColumnNode = BaseNode.extend({ lowerBandCarvingLevel: z.number().int().min(0).max(4).default(0), dentilCount: z.number().int().min(0).max(48).default(0), beadCount: z.number().int().min(0).max(64).default(0), + supportStyle: ColumnSupportStyle.default('vertical'), + braceWidth: z.number().positive().default(0.16), + braceDepth: z.number().positive().default(0.16), + braceBottomSpread: z.number().min(0.2).default(1.2), + braceTopSpread: z.number().min(0).default(0.12), + bracePlateEnabled: z.boolean().default(true), material: MaterialSchema.optional(), materialPreset: z.string().optional(), }).describe(dedent` @@ -150,6 +171,7 @@ export const ColumnNode = BaseNode.extend({ - baseStyle/capitalStyle: procedural base and top treatment with tier/detail controls - baseHeight/capitalHeight: bottom and top block proportions - ring/flute/spiral/panel/lathe/carving fields: procedural detail controls + - supportStyle/brace fields: vertical column or procedural support assembly `) export const COLUMN_PRESETS = { @@ -403,6 +425,566 @@ export const COLUMN_PRESETS = { dentilCount: 0, beadCount: 0, }, + aFrameSupport: { + label: 'A-Frame Support', + supportStyle: 'a-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.6, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 1.2, + braceTopSpread: 0.06, + bracePlateEnabled: false, + }, + yFrameSupport: { + label: 'Y Support', + supportStyle: 'y-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.7, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 0.2, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + vFrameSupport: { + label: 'V Support', + supportStyle: 'v-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.6, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 0.2, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + xBraceSupport: { + label: 'X Brace', + supportStyle: 'x-brace', + style: 'faceted', + crossSection: 'rectangular', + height: 2.6, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + kBraceSupport: { + label: 'K Brace', + supportStyle: 'k-brace', + style: 'faceted', + crossSection: 'rectangular', + height: 2.6, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + singleStrutSupport: { + label: 'Single Strut', + supportStyle: 'single-strut', + style: 'faceted', + crossSection: 'rectangular', + height: 2.6, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + tripodSupport: { + label: 'Tripod Support', + supportStyle: 'tripod', + style: 'faceted', + crossSection: 'rectangular', + height: 2.6, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1.1, + braceTopSpread: 1.1, + bracePlateEnabled: false, + }, + trestleSupport: { + label: 'Trestle Frame', + supportStyle: 'trestle', + style: 'faceted', + crossSection: 'rectangular', + height: 2.6, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1.2, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + portalFrameSupport: { + label: 'Portal Frame', + supportStyle: 'portal-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.6, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 1.4, + braceTopSpread: 0.2, + bracePlateEnabled: false, + }, + boxFrameSupport: { + label: 'Box Frame', + supportStyle: 'box-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.6, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1.4, + braceTopSpread: 1, + bracePlateEnabled: false, + }, } as const satisfies Record>> export type ColumnPresetId = keyof typeof COLUMN_PRESETS diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index 234da4073..fe84c8857 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -23,6 +23,7 @@ export const FenceNode = BaseNode.extend({ groundClearance: z.number().default(0), edgeInset: z.number().default(0.015), baseStyle: FenceBaseStyle.default('grounded'), + showInfill: z.boolean().default(true), color: z.string().default('#ffffff'), style: FenceStyle.default('slat'), }).describe( @@ -33,6 +34,7 @@ export const FenceNode = BaseNode.extend({ - height/thickness: overall fence dimensions in meters - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - groundClearance/edgeInset/baseStyle: fence support and inset configuration + - showInfill: whether to draw intermediate posts/slats between end posts - color/style: visual appearance options `, ) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 7c91df600..b74e45151 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -1768,6 +1768,56 @@ function getRotatedRectanglePolygon( function getColumnPlanFootprint(column: ColumnNode): Point2D[] { const center = { x: column.position[0], y: column.position[2] } + + if ( + column.supportStyle === 'a-frame' || + column.supportStyle === 'y-frame' || + column.supportStyle === 'v-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ) { + const width = Math.max( + column.supportStyle === 'a-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ? (column.braceBottomSpread ?? 1.2) + : 0, + column.braceTopSpread ?? + (column.supportStyle === 'y-frame' || + column.supportStyle === 'v-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ? 1 + : 0), + (column.braceWidth ?? column.width) * 2, + ) + const depth = Math.max( + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'box-frame' + ? (column.braceTopSpread ?? 1) + : 0, + column.braceDepth ?? column.depth, + 0.08, + ) + return getRotatedRectanglePolygon(center, width, depth, column.rotation) + } + const shaftWidth = column.crossSection === 'round' || column.crossSection === 'octagonal' || @@ -5742,6 +5792,12 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ 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), @@ -5792,7 +5848,7 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ strokeWidth={fenceStrokeWidth} vectorEffect="non-scaling-stroke" /> - {markerFrames.map(({ angleDeg, point }, markerIndex) => { + {visibleMarkerFrames.map(({ angleDeg, point }, markerIndex) => { const svgPoint = toSvgPoint(point) if (fence.style === 'privacy') { diff --git a/packages/editor/src/components/ui/panels/column-panel.tsx b/packages/editor/src/components/ui/panels/column-panel.tsx index 8b7e97de4..283dc2da8 100644 --- a/packages/editor/src/components/ui/panels/column-panel.tsx +++ b/packages/editor/src/components/ui/panels/column-panel.tsx @@ -15,6 +15,7 @@ import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' +import { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' const SELECT_CLASS = @@ -83,6 +84,7 @@ function presetUpdates(presetId: ColumnPresetId): Partial { const { label, ...preset } = COLUMN_PRESETS[presetId] return { name: label, + supportStyle: 'supportStyle' in preset ? preset.supportStyle : 'vertical', ...preset, } } @@ -199,6 +201,18 @@ export function ColumnPanel() { if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null const shaftProfile = node.shaftProfile ?? 'straight' + const supportStyle = node.supportStyle ?? 'vertical' + const isBraceSupport = + supportStyle === 'a-frame' || + supportStyle === 'y-frame' || + supportStyle === 'v-frame' || + supportStyle === 'x-brace' || + supportStyle === 'k-brace' || + supportStyle === 'single-strut' || + supportStyle === 'tripod' || + supportStyle === 'trestle' || + supportStyle === 'portal-frame' || + supportStyle === 'box-frame' return ( @@ -223,53 +237,115 @@ export function ColumnPanel() { - handleUpdate({ edgeSoftness: value })} - precision={3} - step={0.005} - unit="m" - value={node.edgeSoftness ?? 0.025} - /> - {(node.crossSection === 'square' || node.crossSection === 'rectangular') && ( - handleUpdate({ shaftCornerRadius: value })} - precision={3} - step={0.005} - unit="m" - value={node.shaftCornerRadius ?? 0.035} - /> + {isBraceSupport ? ( + <> + handleUpdate({ braceWidth: value, width: value })} + precision={2} + step={0.01} + unit="m" + value={node.braceWidth ?? node.width} + /> + handleUpdate({ braceDepth: value, depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.braceDepth ?? node.depth} + /> + + ) : ( + <> + + handleUpdate({ edgeSoftness: value })} + precision={3} + step={0.005} + unit="m" + value={node.edgeSoftness ?? 0.025} + /> + {(node.crossSection === 'square' || node.crossSection === 'rectangular') && ( + handleUpdate({ shaftCornerRadius: value })} + precision={3} + step={0.005} + unit="m" + value={node.shaftCornerRadius ?? 0.035} + /> + )} + )} - + {!isBraceSupport && ( + + )} - - handleUpdate({ - width: value, - radius: value / 2, - ...(node.crossSection === 'rectangular' ? {} : { depth: value }), - }) - } - precision={2} - step={0.02} - unit="m" - value={node.width} - /> - {node.crossSection === 'rectangular' && ( - handleUpdate({ depth: value })} - precision={2} - step={0.02} - unit="m" - value={node.depth} - /> - )} - - - - - {shaftProfile === 'straight' && ( - handleUpdate({ shaftStartScale: value, shaftEndScale: value })} - precision={2} - step={0.02} - value={node.shaftStartScale ?? 0.72} - /> - )} - {shaftProfile === 'tapered' && ( + {isBraceSupport ? ( <> + {(supportStyle === 'a-frame' || + supportStyle === 'x-brace' || + supportStyle === 'k-brace' || + supportStyle === 'single-strut' || + supportStyle === 'tripod' || + supportStyle === 'trestle' || + supportStyle === 'portal-frame' || + supportStyle === 'box-frame') && ( + + handleUpdate({ + braceBottomSpread: value, + braceTopSpread: + supportStyle === 'a-frame' + ? Math.min(node.braceTopSpread ?? 0.12, value) + : (node.braceTopSpread ?? 1), + }) + } + precision={2} + step={0.05} + unit="m" + value={node.braceBottomSpread ?? 1.2} + /> + )} handleUpdate({ shaftStartScale: value })} - precision={2} - step={0.02} - value={node.shaftStartScale ?? 0.82} - /> - handleUpdate({ shaftEndScale: value })} + label={supportStyle === 'y-frame' ? 'Fork Spread' : 'Top Spread'} + max={ + supportStyle === 'y-frame' || + supportStyle === 'v-frame' || + supportStyle === 'x-brace' || + supportStyle === 'k-brace' || + supportStyle === 'single-strut' || + supportStyle === 'tripod' || + supportStyle === 'trestle' || + supportStyle === 'box-frame' + ? 4 + : Math.max(0.2, node.braceBottomSpread ?? 1.2) + } + min={0} + onChange={(value) => handleUpdate({ braceTopSpread: value })} precision={2} step={0.02} - value={node.shaftEndScale ?? 0.72} + unit="m" + value={ + node.braceTopSpread ?? + (supportStyle === 'y-frame' || + supportStyle === 'v-frame' || + supportStyle === 'x-brace' || + supportStyle === 'k-brace' || + supportStyle === 'single-strut' || + supportStyle === 'tripod' || + supportStyle === 'trestle' || + supportStyle === 'portal-frame' || + supportStyle === 'box-frame' + ? 1 + : 0.12) + } /> - handleUpdate({ shaftTaper: value })} - precision={2} - step={0.01} - value={node.shaftTaper ?? 0.14} + handleUpdate({ bracePlateEnabled: checked })} /> - )} - {shaftProfile === 'bulged' && ( + ) : ( <> handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + label="Width" + max={1.6} + min={0.12} + onChange={(value) => + handleUpdate({ + width: value, + radius: value / 2, + ...(node.crossSection === 'rectangular' ? {} : { depth: value }), + }) + } precision={2} step={0.02} - value={node.shaftStartScale ?? 0.68} - /> - handleUpdate({ shaftBulge: value })} - precision={2} - step={0.01} - value={node.shaftBulge ?? 0.12} + unit="m" + value={node.width} /> + {node.crossSection === 'rectangular' && ( + handleUpdate({ depth: value })} + precision={2} + step={0.02} + unit="m" + value={node.depth} + /> + )} )} - {shaftProfile === 'hourglass' && ( - <> + + + {!isBraceSupport && ( + + + {shaftProfile === 'straight' && ( handleUpdate({ shaftStartScale: value, shaftEndScale: value })} precision={2} step={0.02} - value={node.shaftStartScale ?? 0.84} + value={node.shaftStartScale ?? 0.72} /> + )} + {shaftProfile === 'tapered' && ( + <> + handleUpdate({ shaftStartScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.82} + /> + handleUpdate({ shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftEndScale ?? 0.72} + /> + handleUpdate({ shaftTaper: value })} + precision={2} + step={0.01} + value={node.shaftTaper ?? 0.14} + /> + + )} + {shaftProfile === 'bulged' && ( + <> + + handleUpdate({ shaftStartScale: value, shaftEndScale: value }) + } + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.68} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + {shaftProfile === 'hourglass' && ( + <> + + handleUpdate({ shaftStartScale: value, shaftEndScale: value }) + } + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.84} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + + handleUpdate({ + shaftTwistStep: value, + ...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8 + ? { shaftSegmentCount: 12 } + : {}), + }) + } + precision={0} + step={5} + unit="°" + value={node.shaftTwistStep ?? 0} + /> + {Math.abs(node.shaftTwistStep ?? 0) > 0.001 && ( handleUpdate({ shaftBulge: value })} - precision={2} - step={0.01} - value={node.shaftBulge ?? 0.12} + label="Twist Segments" + max={48} + min={4} + onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })} + precision={0} + step={1} + value={node.shaftSegmentCount ?? 12} /> - - )} - - handleUpdate({ - shaftTwistStep: value, - ...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8 - ? { shaftSegmentCount: 12 } - : {}), - }) - } - precision={0} - step={5} - unit="°" - value={node.shaftTwistStep ?? 0} - /> - {Math.abs(node.shaftTwistStep ?? 0) > 0.001 && ( + )} handleUpdate({ shaftSegmentCount: Math.round(value) })} + label="Ring Pairs" + max={4} + min={0} + onChange={(value) => + handleUpdate({ + ringCount: Math.round(value) * 2, + ringPlacement: 'ends', + ringSpread: node.ringSpread ?? 0.16, + ringThickness: node.ringThickness ?? 0.055, + }) + } precision={0} step={1} - value={node.shaftSegmentCount ?? 12} - /> - )} - - handleUpdate({ - ringCount: Math.round(value) * 2, - ringPlacement: 'ends', - ringSpread: node.ringSpread ?? 0.16, - ringThickness: node.ringThickness ?? 0.055, - }) - } - precision={0} - step={1} - value={Math.ceil((node.ringCount ?? 0) / 2)} - /> - {(node.ringCount ?? 0) > 0 && ( - handleUpdate({ ringThickness: value })} - precision={3} - step={0.005} - unit="m" - value={node.ringThickness ?? 0.055} + value={Math.ceil((node.ringCount ?? 0) / 2)} /> - )} - {(node.ringCount ?? 0) > 0 && ( - handleUpdate({ ringSpread: value, ringPlacement: 'ends' })} - precision={2} - step={0.01} - value={node.ringSpread ?? 0.16} - /> - )} - + {(node.ringCount ?? 0) > 0 && ( + handleUpdate({ ringThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.ringThickness ?? 0.055} + /> + )} + {(node.ringCount ?? 0) > 0 && ( + handleUpdate({ ringSpread: value, ringPlacement: 'ends' })} + precision={2} + step={0.01} + value={node.ringSpread ?? 0.16} + /> + )} + + )} + {!isBraceSupport && ( { - const nextSupportStyle = event.target.value as ColumnNode['supportStyle'] - handleUpdate({ - supportStyle: nextSupportStyle, - ...(nextSupportStyle !== 'vertical' - ? { - crossSection: 'rectangular', - width: node.braceWidth ?? node.width, - depth: node.braceDepth ?? node.depth, - baseStyle: 'none', - capitalStyle: 'none', - } - : {}), - }) - }} - value={supportStyle} - > - - - - - - - - - - - - +
+ {SUPPORT_STYLE_OPTIONS.map((option) => { + const isSelected = supportStyle === option.value + return ( + + ) + })} +
{isBraceSupport ? ( <> { for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1]) shape.closePath() - if (slabPolygons.length > 0) { - const multiPolygons = slabPolygons.map((p) => [ - p.map((pt) => [pt[0], -pt[1]] as [number, number]), - ]) - const unioned = polygonClipping.union( - multiPolygons[0] as polygonClipping.Polygon, - ...(multiPolygons.slice(1) as polygonClipping.Polygon[]), - ) - for (const geom of unioned) { - const ring = geom[0] - if (ring && ring.length > 0) { - const hole = new Path() - hole.moveTo(ring[0]![0], ring[0]![1]) - for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1]) - hole.closePath() - shape.holes.push(hole) - } + for (const polygon of slabPolygons) { + if (polygon.length < 3) continue + + const hole = new Path() + hole.moveTo(polygon[0]![0], -polygon[0]![1]) + for (let i = 1; i < polygon.length; i++) { + hole.lineTo(polygon[i]![0], -polygon[i]![1]) } + hole.closePath() + shape.holes.push(hole) } return shape diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx index 54be8b51a..c90cafa67 100644 --- a/packages/viewer/src/components/viewer/ground-occluder.tsx +++ b/packages/viewer/src/components/viewer/ground-occluder.tsx @@ -1,5 +1,4 @@ import { type LevelNode, useScene } from '@pascal-app/core' -import polygonClipping from 'polygon-clipping' import { useMemo } from 'react' import * as THREE from 'three' import useViewer from '../../store/use-viewer' @@ -63,33 +62,16 @@ export const GroundOccluder = () => { polygons.push(node.polygon as [number, number][]) }) - if (polygons.length > 0) { - // Format for polygon-clipping: [[[x, y], [x, y], ...]] - const multiPolygons = polygons.map((pts) => { - const ring = pts.map((p) => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z) - return [ring] - }) + for (const polygon of polygons) { + if (polygon.length < 3) continue - // Union all polygons together to prevent artifacts from overlapping - const unionedPolygons = polygonClipping.union(multiPolygons[0]!, ...multiPolygons.slice(1)) - - // Add each resulting unioned polygon as a hole - for (const geom of unionedPolygons) { - // First ring in each geometry is the exterior ring - if (geom.length > 0) { - const ring = geom[0]! - const hole = new THREE.Path() - - if (ring.length > 0) { - hole.moveTo(ring[0]![0], ring[0]![1]) - for (let i = 1; i < ring.length; i++) { - hole.lineTo(ring[i]![0], ring[i]![1]) - } - hole.closePath() - s.holes.push(hole) - } - } + const hole = new THREE.Path() + hole.moveTo(polygon[0]![0], -polygon[0]![1]) + for (let i = 1; i < polygon.length; i++) { + hole.lineTo(polygon[i]![0], -polygon[i]![1]) } + hole.closePath() + s.holes.push(hole) } return s From afadfb2ea8a3fd708a8204280e99dab31359c39f Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 11 May 2026 11:24:44 +0530 Subject: [PATCH 4/9] Guard viewer BVH against empty geometries --- .../viewer/src/components/viewer/index.tsx | 6 +- .../src/components/viewer/scene-bvh.tsx | 137 ++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 packages/viewer/src/components/viewer/scene-bvh.tsx diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index bc2b5b85b..f4d9bd9bc 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,5 @@ 'use client' -import { Bvh } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' @@ -28,6 +27,7 @@ import FrameLimiter from './frame-limiter' import { Lights } from './lights' import { PerfMonitor } from './perf-monitor' import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing' +import { SceneBvh } from './scene-bvh' import { SelectionManager } from './selection-manager' import { ViewerCamera } from './viewer-camera' @@ -216,9 +216,9 @@ const Viewer: React.FC = ({ {/* */} - + - + {/* Default Systems */} diff --git a/packages/viewer/src/components/viewer/scene-bvh.tsx b/packages/viewer/src/components/viewer/scene-bvh.tsx new file mode 100644 index 000000000..c4517e6b5 --- /dev/null +++ b/packages/viewer/src/components/viewer/scene-bvh.tsx @@ -0,0 +1,137 @@ +import { useThree } from '@react-three/fiber' +import { + type ReactNode, + forwardRef, + useEffect, + useImperativeHandle, + useRef, +} from 'react' +import { Group, Mesh, type BufferGeometry } from 'three' +import { + SAH, + acceleratedRaycast, + computeBoundsTree, + disposeBoundsTree, + type SplitStrategy, +} from 'three-mesh-bvh' + +type SceneBvhProps = { + children?: ReactNode + enabled?: boolean + firstHitOnly?: boolean + strategy?: SplitStrategy + verbose?: boolean + setBoundingBox?: boolean + maxDepth?: number + maxLeafSize?: number + indirect?: boolean +} + +const isMesh = (object: unknown): object is Mesh => + !!object && typeof object === 'object' && (object as Mesh).isMesh === true + +const hasBvhCompatibleGeometry = (geometry?: BufferGeometry | null) => { + if (!geometry) return false + + const position = geometry.getAttribute('position') + if (!position) return false + + const vertexCount = geometry.getIndex()?.count ?? position.count + return vertexCount >= 3 +} + +export const SceneBvh = forwardRef( + ( + { + children, + enabled = true, + firstHitOnly = false, + strategy = SAH, + verbose = false, + setBoundingBox = true, + maxDepth = 40, + maxLeafSize = 10, + indirect = false, + }, + forwardedRef, + ) => { + const ref = useRef(null) + const raycaster = useThree((state) => state.raycaster) + + useImperativeHandle(forwardedRef, () => ref.current!, []) + + useEffect(() => { + if (!enabled || !ref.current) return + + const options = { + strategy, + verbose, + setBoundingBox, + maxDepth, + maxLeafSize, + indirect, + } + const group = ref.current + const acceleratedMeshes = new Set() + const computedGeometries = new Set() + + ;(raycaster as any).firstHitOnly = firstHitOnly + + group.traverse((child) => { + if (!isMesh(child)) return + + if (child.raycast === Mesh.prototype.raycast) { + child.raycast = acceleratedRaycast + acceleratedMeshes.add(child) + } + + if (child.raycast !== acceleratedRaycast) return + + const geometry = child.geometry + if (geometry.boundsTree || !hasBvhCompatibleGeometry(geometry)) return + + try { + geometry.computeBoundsTree = computeBoundsTree + geometry.disposeBoundsTree = disposeBoundsTree + geometry.computeBoundsTree(options) + computedGeometries.add(geometry) + } catch (error) { + console.warn('[viewer] Skipping BVH for incompatible mesh geometry.', { + mesh: child.name || child.type, + error, + }) + } + }) + + return () => { + delete (raycaster as any).firstHitOnly + + for (const geometry of computedGeometries) { + if (geometry.boundsTree) { + geometry.disposeBoundsTree() + } + } + + for (const mesh of acceleratedMeshes) { + if (mesh.raycast === acceleratedRaycast) { + mesh.raycast = Mesh.prototype.raycast + } + } + } + }, [ + enabled, + firstHitOnly, + strategy, + verbose, + setBoundingBox, + maxDepth, + maxLeafSize, + indirect, + raycaster, + ]) + + return {children} + }, +) + +SceneBvh.displayName = 'SceneBvh' From be4151147456cc5f36643908b03035b10352386f Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 11 May 2026 23:46:57 +0530 Subject: [PATCH 5/9] Fix wall move junction ownership --- packages/core/src/index.ts | 9 + .../core/src/store/actions/node-actions.ts | 142 +++++++- packages/core/src/store/use-scene.ts | 6 + packages/core/src/systems/wall/wall-move.ts | 227 +++++++++++++ .../components/editor/selection-manager.tsx | 12 +- .../components/tools/wall/move-wall-tool.tsx | 315 ++++++++++++++---- .../renderers/item/item-renderer.tsx | 3 + .../renderers/wall/wall-renderer.tsx | 45 ++- packages/viewer/src/lib/materials.ts | 10 +- 9 files changed, 691 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/systems/wall/wall-move.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d3875421f..e147e18c1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -107,6 +107,15 @@ export { type WallMiterBoundaryPoints, type WallMiterData, } from './systems/wall/wall-mitering' +export { + constrainWallMoveDeltaToAxis, + getPerpendicularWallMoveAxis, + planWallMoveJunctions, + type WallMoveBridgePlan, + type WallMoveAxis, + type WallMoveJunctionPlan, + type WallPlanPoint, +} from './systems/wall/wall-move' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index d9e50b79b..b65c03d02 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -9,6 +9,9 @@ import type { CollectionId } from '../../schema/collections' import type { SceneState } from '../use-scene' type AnyContainerNode = AnyNode & { children: string[] } +type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId } +type NodeUpdateOp = { id: AnyNodeId; data: Partial } +type NodeDeleteOp = AnyNodeId type WallAttachmentUpdate = { id: AnyNodeId; data: Partial } type WallMergePlan = { primaryWallId: AnyNodeId @@ -230,7 +233,7 @@ function buildWallMergePlans( export const createNodesAction = ( set: (fn: (state: SceneState) => Partial) => void, get: () => SceneState, - ops: { node: AnyNode; parentId?: AnyNodeId }[], + ops: NodeCreateOp[], ) => { if (get().readOnly) return set((state) => { @@ -278,6 +281,143 @@ export const createNodesAction = ( }) } +export const applyNodeChangesAction = ( + set: (fn: (state: SceneState) => Partial) => void, + get: () => SceneState, + changes: { create?: NodeCreateOp[]; update?: NodeUpdateOp[]; delete?: NodeDeleteOp[] }, +) => { + if (get().readOnly) return + + const createOps = changes.create ?? [] + const updateOps = changes.update ?? [] + const deleteOps = changes.delete ?? [] + const nodesToMarkDirty = new Set() + const parentsToMarkDirty = new Set() + + set((state) => { + const nextNodes = { ...state.nodes } + const nextCollections = { ...state.collections } + const nextRootIds = [...state.rootNodeIds] + let resolvedRootIds = nextRootIds + + for (const { id, data } of updateOps) { + const currentNode = nextNodes[id] + if (!currentNode) continue + + if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { + const oldParentId = currentNode.parentId as AnyNodeId | null + if (oldParentId && nextNodes[oldParentId]) { + const oldParent = nextNodes[oldParentId] as AnyContainerNode + nextNodes[oldParent.id] = { + ...oldParent, + children: oldParent.children.filter((childId) => childId !== id), + } as AnyNode + parentsToMarkDirty.add(oldParent.id) + } + + const newParentId = data.parentId as AnyNodeId | null + if (newParentId && nextNodes[newParentId]) { + const newParent = nextNodes[newParentId] as AnyContainerNode + nextNodes[newParent.id] = { + ...newParent, + children: Array.from(new Set([...newParent.children, id])), + } as AnyNode + parentsToMarkDirty.add(newParent.id) + } + } + + nextNodes[id] = { ...currentNode, ...data } as AnyNode + nodesToMarkDirty.add(id) + } + + for (const { node, parentId } of createOps) { + const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null + const newNode = { + ...node, + parentId: effectiveParentId, + } as AnyNode + + nextNodes[newNode.id as AnyNodeId] = newNode + nodesToMarkDirty.add(newNode.id as AnyNodeId) + + if (effectiveParentId && nextNodes[effectiveParentId]) { + const parent = nextNodes[effectiveParentId] + if ('children' in parent && Array.isArray(parent.children)) { + nextNodes[effectiveParentId] = { + ...parent, + children: Array.from(new Set([...parent.children, newNode.id])) as any, + } + parentsToMarkDirty.add(effectiveParentId) + } + } else if (!effectiveParentId && !nextRootIds.includes(newNode.id as AnyNodeId)) { + nextRootIds.push(newNode.id as AnyNodeId) + } + } + + const allIdsToDelete = new Set() + const collectDelete = (id: AnyNodeId) => { + if (allIdsToDelete.has(id)) return + allIdsToDelete.add(id) + const node = nextNodes[id] + if (node && 'children' in node && Array.isArray(node.children)) { + for (const childId of node.children) { + collectDelete(childId as AnyNodeId) + } + } + } + + for (const id of deleteOps) { + collectDelete(id) + } + + for (const id of allIdsToDelete) { + const node = nextNodes[id] + if (!node) continue + + const parentId = node.parentId as AnyNodeId | null + if (parentId && nextNodes[parentId] && !allIdsToDelete.has(parentId)) { + const parent = nextNodes[parentId] as AnyContainerNode + if (parent.children) { + nextNodes[parent.id] = { + ...parent, + children: parent.children.filter((childId) => childId !== id), + } as AnyNode + parentsToMarkDirty.add(parent.id) + } + } + + resolvedRootIds = resolvedRootIds.filter((rootId) => rootId !== id) + + if ('collectionIds' in node && node.collectionIds) { + for (const collectionId of node.collectionIds as CollectionId[]) { + const collection = nextCollections[collectionId] + if (collection) { + nextCollections[collectionId] = { + ...collection, + nodeIds: collection.nodeIds.filter((nodeId) => nodeId !== id), + } + } + } + } + + delete nextNodes[id] + } + + return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections } + }) + + nodesToMarkDirty.forEach((id) => get().markDirty(id)) + parentsToMarkDirty.forEach((id) => { + get().markDirty(id) + const parent = get().nodes[id] + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) { + get().markDirty(childId as AnyNodeId) + } + } + }) +} + export const updateNodesAction = ( set: (fn: (state: SceneState) => Partial) => void, get: () => SceneState, diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 51e1caa9f..dece8061b 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -438,6 +438,11 @@ export type SceneState = { createNode: (node: AnyNode, parentId?: AnyNodeId) => void createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void + applyNodeChanges: (changes: { + create?: { node: AnyNode; parentId?: AnyNodeId }[] + update?: { id: AnyNodeId; data: Partial }[] + delete?: AnyNodeId[] + }) => void updateNode: (id: AnyNodeId, data: Partial) => void updateNodes: (updates: { id: AnyNodeId; data: Partial }[]) => void @@ -579,6 +584,7 @@ const useScene: UseSceneStore = create()( createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]), + applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes), updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates), updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]), diff --git a/packages/core/src/systems/wall/wall-move.ts b/packages/core/src/systems/wall/wall-move.ts new file mode 100644 index 000000000..e9b6595c4 --- /dev/null +++ b/packages/core/src/systems/wall/wall-move.ts @@ -0,0 +1,227 @@ +import type { WallNode } from '../../schema' + +const AXIS_EPSILON = 1e-6 + +export type WallPlanPoint = [number, number] +export type WallMoveAxis = 'x' | 'z' +export type WallMoveEndpoint = 'start' | 'end' + +export type WallMoveBridgePlan> = { + wall: TWall + originalPoint: WallPlanPoint + movedEndpoint: WallMoveEndpoint +} + +export type WallMoveLinkedWallTargetPlan< + TWall extends Pick, +> = { + wall: TWall + originalPoint: WallPlanPoint + targetPoint: WallPlanPoint +} + +export type WallMoveJunctionPlan> = { + linkedWallsToMove: TWall[] + linkedWallTargetPlans: Array> + bridgePlans: Array> + wallsToDelete: TWall[] +} + +export function getPerpendicularWallMoveAxis( + start: WallPlanPoint, + end: WallPlanPoint, +): WallMoveAxis | null { + const wallDeltaX = Math.abs(end[0] - start[0]) + const wallDeltaZ = Math.abs(end[1] - start[1]) + + if (wallDeltaX < AXIS_EPSILON && wallDeltaZ < AXIS_EPSILON) return null + + return wallDeltaX >= wallDeltaZ ? 'z' : 'x' +} + +export function constrainWallMoveDeltaToAxis( + deltaX: number, + deltaZ: number, + axis: WallMoveAxis | null, +): WallPlanPoint { + if (axis === 'x') return [deltaX, 0] + if (axis === 'z') return [0, deltaZ] + return [deltaX, deltaZ] +} + +function pointsEqual(a: WallPlanPoint, b: WallPlanPoint) { + return Math.abs(a[0] - b[0]) <= AXIS_EPSILON && Math.abs(a[1] - b[1]) <= AXIS_EPSILON +} + +function wallTouchesPoint(wall: Pick, point: WallPlanPoint) { + return pointsEqual(wall.start, point) || pointsEqual(wall.end, point) +} + +function otherWallEndpoint(wall: Pick, point: WallPlanPoint) { + return pointsEqual(wall.start, point) ? wall.end : wall.start +} + +type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary' +type RelatedWallEntry> = { + wall: TWall + relation: MoveWallRelation +} + +function wallLengthFromPoint(wall: Pick, point: WallPlanPoint) { + const freeEndpoint = otherWallEndpoint(wall, point) + return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1]) +} + +function getMoveWallRelation( + wall: Pick, + sharedPoint: WallPlanPoint, + nextPoint: WallPlanPoint, +): MoveWallRelation { + const moveX = nextPoint[0] - sharedPoint[0] + const moveZ = nextPoint[1] - sharedPoint[1] + const moveLength = Math.hypot(moveX, moveZ) + + if (moveLength < AXIS_EPSILON) return 'stationary' + + const freeEndpoint = otherWallEndpoint(wall, sharedPoint) + const wallX = freeEndpoint[0] - sharedPoint[0] + const wallZ = freeEndpoint[1] - sharedPoint[1] + const wallLength = Math.hypot(wallX, wallZ) + + if (wallLength < AXIS_EPSILON) return 'stationary' + + const normalizedCross = Math.abs(moveX * wallZ - moveZ * wallX) / (moveLength * wallLength) + if (normalizedCross > 1e-4) return 'off-axis' + + const normalizedDot = (moveX * wallX + moveZ * wallZ) / (moveLength * wallLength) + return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction' +} + +export function planWallMoveJunctions>( + linkedWalls: TWall[], + originalStart: WallPlanPoint, + originalEnd: WallPlanPoint, + nextStart: WallPlanPoint, + nextEnd: WallPlanPoint, +): WallMoveJunctionPlan { + const linkedWallsToMove = new Map() + const linkedWallTargetPlans = new Map>() + const bridgePlans = new Map>() + const wallsToDelete = new Map() + + const addStandardEndpointPlan = ( + endpoint: WallMoveEndpoint, + point: WallPlanPoint, + nextPoint: WallPlanPoint, + relatedWalls: Array>, + keySuffix = '', + useTargetPlans = false, + ) => { + const hasSideBranch = relatedWalls.some((entry) => entry.relation === 'off-axis') + const hasOppositeBridge = relatedWalls.some( + (entry) => entry.relation === 'opposite-direction' && hasSideBranch, + ) + + for (const { wall, relation } of relatedWalls) { + if ( + relation === 'stationary' || + relation === 'same-direction' || + (relation === 'opposite-direction' && !hasSideBranch) + ) { + if (useTargetPlans) { + linkedWallTargetPlans.set(wall.id, { + wall, + originalPoint: point, + targetPoint: nextPoint, + }) + } else { + linkedWallsToMove.set(wall.id, wall) + } + continue + } + + if (relation === 'off-axis' && hasOppositeBridge) { + continue + } + + bridgePlans.set(`${wall.id}:${endpoint}${keySuffix}`, { + wall, + originalPoint: point, + movedEndpoint: endpoint, + }) + } + } + + const addEndpointPlan = ( + endpoint: WallMoveEndpoint, + point: WallPlanPoint, + nextPoint: WallPlanPoint, + ) => { + const moveLength = Math.hypot(nextPoint[0] - point[0], nextPoint[1] - point[1]) + const linkedAtEndpoint = linkedWalls + .filter((wall) => wallTouchesPoint(wall, point)) + .map((wall) => ({ + wall, + relation: getMoveWallRelation(wall, point, nextPoint), + })) + const consumedSameDirectionWall = linkedAtEndpoint + .filter((entry) => entry.relation === 'same-direction') + .map((entry) => ({ + ...entry, + distance: wallLengthFromPoint(entry.wall, point), + })) + .filter((entry) => moveLength + AXIS_EPSILON >= entry.distance) + .sort((a, b) => a.distance - b.distance)[0] + + if (consumedSameDirectionWall) { + const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint + const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction') + + wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall) + linkedWallTargetPlans.set(consumedSameDirectionWall.wall.id, { + wall: consumedSameDirectionWall.wall, + originalPoint: point, + targetPoint: pivotPoint, + }) + + if (bridgeSource) { + linkedWallTargetPlans.set(bridgeSource.wall.id, { + wall: bridgeSource.wall, + originalPoint: point, + targetPoint: pivotPoint, + }) + + bridgePlans.set(`${bridgeSource.wall.id}:${endpoint}:through`, { + wall: bridgeSource.wall, + originalPoint: pivotPoint, + movedEndpoint: endpoint, + }) + return + } + + const linkedAtPivot = linkedWalls + .filter( + (wall) => wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint), + ) + .map((wall) => ({ + wall, + relation: getMoveWallRelation(wall, pivotPoint, nextPoint), + })) + + addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true) + return + } + + addStandardEndpointPlan(endpoint, point, nextPoint, linkedAtEndpoint) + } + + addEndpointPlan('start', originalStart, nextStart) + addEndpointPlan('end', originalEnd, nextEnd) + + return { + linkedWallsToMove: Array.from(linkedWallsToMove.values()), + linkedWallTargetPlans: Array.from(linkedWallTargetPlans.values()), + bridgePlans: Array.from(bridgePlans.values()), + wallsToDelete: Array.from(wallsToDelete.values()), + } +} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 71f278cf1..af3655458 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1636,6 +1636,7 @@ const EditorOutlinerSync = () => { const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const outliner = useViewer((s) => s.outliner) + const nodes = useScene((s) => s.nodes) useEffect(() => { let idsToHighlight: string[] = [] @@ -1672,16 +1673,21 @@ const EditorOutlinerSync = () => { // 2. Sync with the imperative outliner arrays (mutate in place to keep references) outliner.selectedObjects.length = 0 for (const id of idsToHighlight) { + if (!nodes[id as AnyNodeId]) continue const obj = sceneRegistry.nodes.get(id) if (obj?.parent) outliner.selectedObjects.push(obj) } outliner.hoveredObjects.length = 0 if (hoveredId) { - const obj = sceneRegistry.nodes.get(hoveredId) - if (obj?.parent) outliner.hoveredObjects.push(obj) + if (!nodes[hoveredId as AnyNodeId]) { + useViewer.setState({ hoveredId: null }) + } else { + const obj = sceneRegistry.nodes.get(hoveredId) + if (obj?.parent) outliner.hoveredObjects.push(obj) + } } - }, [phase, previewSelectedIds, selection, hoveredId, outliner]) + }, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) return null } diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx index cd82aa348..cd9b5ea12 100644 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-tool.tsx @@ -2,12 +2,19 @@ import { type AnyNodeId, + constrainWallMoveDeltaToAxis, emitter, + getPerpendicularWallMoveAxis, type GridEvent, pauseSceneHistory, + planWallMoveJunctions, resumeSceneHistory, useScene, + type WallMoveBridgePlan, + type WallMoveAxis, + type WallMoveJunctionPlan, type WallNode, + WallNode as WallSchema, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' @@ -15,7 +22,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' -import { getWallGridStep, snapScalarToGrid } from './wall-drafting' +import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting' function rotateVector([x, z]: [number, number], angle: number): [number, number] { const cos = Math.cos(angle) @@ -27,6 +34,10 @@ function samePoint(a: [number, number], b: [number, number]) { return a[0] === b[0] && a[1] === b[1] } +function pointKey(point: [number, number]) { + return `${point[0]}:${point[1]}` +} + function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] { if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { return meta @@ -37,11 +48,7 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata' return nextMeta as WallNode['metadata'] } -type LinkedWallSnapshot = { - id: WallNode['id'] - start: [number, number] - end: [number, number] -} +type LinkedWallSnapshot = WallNode function getLinkedWallSnapshots(args: { wallId: WallNode['id'] @@ -51,30 +58,45 @@ function getLinkedWallSnapshots(args: { }) { const { wallId, wallParentId, originalStart, originalEnd } = args const { nodes } = useScene.getState() - const snapshots: LinkedWallSnapshot[] = [] + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId, + ) + const directlyLinkedWalls = walls.filter( + (wall) => + samePoint(wall.start, originalStart) || + samePoint(wall.start, originalEnd) || + samePoint(wall.end, originalStart) || + samePoint(wall.end, originalEnd), + ) + const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)]) - for (const node of Object.values(nodes)) { - if (!(node?.type === 'wall' && node.id !== wallId)) { - continue - } + for (const wall of directlyLinkedWalls) { + contextPoints.add(pointKey(wall.start)) + contextPoints.add(pointKey(wall.end)) + } - if ((node.parentId ?? null) !== wallParentId) { - continue - } + const snapshots: LinkedWallSnapshot[] = [] + const seenWallIds = new Set() + for (const node of walls) { if ( - !samePoint(node.start, originalStart) && - !samePoint(node.start, originalEnd) && - !samePoint(node.end, originalStart) && - !samePoint(node.end, originalEnd) + !contextPoints.has(pointKey(node.start)) && + !contextPoints.has(pointKey(node.end)) ) { continue } + if (seenWallIds.has(node.id)) { + continue + } + seenWallIds.add(node.id) + snapshots.push({ - id: node.id, + ...node, start: [...node.start] as [number, number], end: [...node.end] as [number, number], + children: [...(node.children ?? [])], }) } @@ -82,25 +104,139 @@ function getLinkedWallSnapshots(args: { } function getLinkedWallUpdates( - linkedWalls: LinkedWallSnapshot[], + linkedWalls: Array<{ + wall: LinkedWallSnapshot + matchPoint?: [number, number] + targetPoint?: [number, number] + }>, + originalStart: [number, number], + originalEnd: [number, number], + nextStart: [number, number], + nextEnd: [number, number], +) { + return linkedWalls.map(({ wall, matchPoint, targetPoint }) => { + if (matchPoint && targetPoint) { + return { + id: wall.id, + start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start, + end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end, + } + } + + const targetStart = targetPoint ?? nextStart + const targetEnd = targetPoint ?? nextEnd + + return { + id: wall.id, + start: samePoint(wall.start, originalStart) + ? targetStart + : samePoint(wall.start, originalEnd) + ? targetEnd + : wall.start, + end: samePoint(wall.end, originalStart) + ? targetStart + : samePoint(wall.end, originalEnd) + ? targetEnd + : wall.end, + } + }) +} + +function getPlannedLinkedWallUpdates( + plan: WallMoveJunctionPlan, originalStart: [number, number], originalEnd: [number, number], nextStart: [number, number], nextEnd: [number, number], ) { - return linkedWalls.map((wall) => ({ - id: wall.id, - start: samePoint(wall.start, originalStart) - ? nextStart - : samePoint(wall.start, originalEnd) - ? nextEnd - : wall.start, - end: samePoint(wall.end, originalStart) - ? nextStart - : samePoint(wall.end, originalEnd) - ? nextEnd - : wall.end, - })) + const movePlans = new Map< + WallNode['id'], + { wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] } + >() + + for (const wall of plan.linkedWallsToMove) { + movePlans.set(wall.id, { wall }) + } + + for (const targetPlan of plan.linkedWallTargetPlans) { + movePlans.set(targetPlan.wall.id, { + wall: targetPlan.wall, + matchPoint: targetPlan.originalPoint, + targetPoint: targetPlan.targetPoint, + }) + } + + return getLinkedWallUpdates( + Array.from(movePlans.values()), + originalStart, + originalEnd, + nextStart, + nextEnd, + ) +} + +function wallSegmentExists(walls: WallNode[], start: [number, number], end: [number, number]) { + return walls.some( + (wall) => + (samePoint(wall.start, start) && samePoint(wall.end, end)) || + (samePoint(wall.start, end) && samePoint(wall.end, start)), + ) +} + +function getWallsAfterUpdates( + nodes: ReturnType['nodes'], + updates: Array<{ id: AnyNodeId; data: Partial }>, +) { + const updateById = new Map(updates.map((update) => [update.id, update.data])) + + return Object.values(nodes) + .filter((node): node is WallNode => node?.type === 'wall') + .map((wall) => { + const update = updateById.get(wall.id as AnyNodeId) + return update ? ({ ...wall, ...update } as WallNode) : wall + }) +} + +function buildBridgeWallCreates(args: { + bridgePlans: Array> + nextStart: [number, number] + nextEnd: [number, number] + existingWalls: WallNode[] + wallCount: number +}): Array<{ node: WallNode; parentId?: AnyNodeId }> { + const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args + const wallsForDuplicateCheck = [...existingWalls] + const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = [] + + for (const plan of bridgePlans) { + const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd + + if (!isWallLongEnough(plan.originalPoint, nextPoint)) { + continue + } + + if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) { + continue + } + + const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall + const bridgeWall = WallSchema.parse({ + ...sourceWall, + name: `Wall ${wallCount + creates.length + 1}`, + start: plan.originalPoint, + end: nextPoint, + children: [], + metadata: stripWallIsNewMetadata(plan.wall.metadata), + }) + + creates.push({ + node: bridgeWall, + parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined, + }) + wallsForDuplicateCheck.push(bridgeWall) + } + + return creates } export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { @@ -121,7 +257,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { (node.end[0] - node.start[0]) / 2, (node.end[1] - node.start[1]) / 2, ]) - const linkedOriginalsRef = useRef( + const moveAxisRef = useRef( + getPerpendicularWallMoveAxis(node.start, node.end), + ) + const linkedOriginalsRef = useRef( isNew ? [] : getLinkedWallSnapshots({ @@ -178,6 +317,31 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { return { start: nextStart, end: nextEnd } } + const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) => + planWallMoveJunctions( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) + + const getLinkedPreviewUpdates = (nextStart: [number, number], nextEnd: [number, number]) => { + const plan = getMovePlan(nextStart, nextEnd) + const movedUpdates = getPlannedLinkedWallUpdates( + plan, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) + const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry])) + + return linkedOriginalsRef.current.map( + (wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end }, + ) + } + const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { previewRef.current = { start: nextStart, end: nextEnd } const centerX = (nextStart[0] + nextEnd[0]) / 2 @@ -185,13 +349,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { setCursorLocalPos([centerX, 0, centerZ]) applyNodePreview([ { id: nodeId, start: nextStart, end: nextEnd }, - ...getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - ), + ...getLinkedPreviewUpdates(nextStart, nextEnd), ]) } @@ -209,19 +367,24 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep) const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep) + const anchor = dragAnchorRef.current ?? [localX, localZ] + dragAnchorRef.current = anchor + + const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis( + localX - anchor[0], + localZ - anchor[1], + moveAxisRef.current, + ) + const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ] + if ( previousGridPosRef.current && - (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) + (constrainedGridPos[0] !== previousGridPosRef.current[0] || + constrainedGridPos[1] !== previousGridPosRef.current[1]) ) { sfxEmitter.emit('sfx:grid-snap') } - previousGridPosRef.current = [localX, localZ] - - const anchor = dragAnchorRef.current ?? [localX, localZ] - dragAnchorRef.current = anchor - - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] + previousGridPosRef.current = constrainedGridPos const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ] const nextWall = buildWallFromCenter(nextCenter) @@ -246,6 +409,22 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { ]) resumeSceneHistory(useScene) + const commitPlan = getMovePlan(preview.start, preview.end) + const linkedWallUpdates = getPlannedLinkedWallUpdates( + commitPlan, + originalStart, + originalEnd, + preview.start, + preview.end, + ) + const collapsedLinkedWallIds = new Set( + [ + ...linkedWallUpdates + .filter((entry) => !isWallLongEnough(entry.start, entry.end)) + .map((entry) => entry.id as AnyNodeId), + ...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId), + ], + ) const commitUpdates = [ { @@ -258,21 +437,30 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } : { start: preview.start, end: preview.end }, }, - ...getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - preview.start, - preview.end, - ).map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), + ...linkedWallUpdates + .filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId)) + .map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), ] - useScene.getState().updateNodes(commitUpdates) - for (const { id } of commitUpdates) { - useScene.getState().markDirty(id) - } + const sceneState = useScene.getState() + const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter( + (wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId), + ) + const bridgeCreates = buildBridgeWallCreates({ + bridgePlans: commitPlan.bridgePlans, + nextStart: preview.start, + nextEnd: preview.end, + existingWalls, + wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall') + .length, + }) + sceneState.applyNodeChanges({ + update: commitUpdates, + create: bridgeCreates, + delete: Array.from(collapsedLinkedWallIds), + }) pauseSceneHistory(useScene) @@ -311,6 +499,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { (preview.start[1] + preview.end[1]) / 2, ] const nextWall = buildWallFromCenter(currentCenter) + moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end) applyPreview(nextWall.start, nextWall.end) } diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 3931e0dac..8ac11b815 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -156,9 +156,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { const lightEffects = interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? [] + // useGLTF caches scenes, and Clone shares child geometry/material references. + // Undo can unmount one item while another clone of the same asset still needs them. return ( <> { const ref = useRef(null!) + const placeholderGeometry = useMemo(createEmptyWallGeometry, []) + const collisionPlaceholderGeometry = useMemo(() => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([], 3)) + return geometry + }, []) useRegistry(node.id, 'wall', ref) @@ -14,15 +29,31 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { useScene.getState().markDirty(node.id) }, [node.id]) + useEffect(() => { + return () => { + placeholderGeometry.dispose() + collisionPlaceholderGeometry.dispose() + } + }, [collisionPlaceholderGeometry, placeholderGeometry]) + const handlers = useNodeEvents(node, 'wall') const material = getVisibleWallMaterials(node) return ( - - - - - + + {node.children.map((childId) => ( diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index b390e4366..4595a6fdd 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -271,16 +271,18 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat } const map = getTexture(material) - - const threeMaterial = new THREE.MeshStandardMaterial({ + const materialParams: THREE.MeshStandardMaterialParameters = { color: props.color, roughness: props.roughness, metalness: props.metalness, opacity: props.opacity, transparent: props.transparent, side: sideMap[props.side], - map, - }) + } + + if (map) materialParams.map = map + + const threeMaterial = new THREE.MeshStandardMaterial(materialParams) materialCache.set(cacheKey, threeMaterial) return threeMaterial From d78308b366d26fda1273b059efbf24517847e23a Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 12 May 2026 00:05:41 +0530 Subject: [PATCH 6/9] Hide inapplicable door and window panel settings --- .../src/components/ui/panels/door-panel.tsx | 50 ++-- .../src/components/ui/panels/window-panel.tsx | 255 +++++++++--------- 2 files changed, 167 insertions(+), 138 deletions(-) diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 01d41bd2c..b447d61fe 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -127,8 +127,11 @@ export function DoorPanel() { const handleUpdate = useCallback( (updates: Partial) => { if (!(selectedId && node)) return + const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] + if (liveNode?.type !== 'door') return + const hasChange = Object.entries(updates).some(([key, value]) => { - const currentValue = node[key as keyof DoorNode] + const currentValue = liveNode[key as keyof DoorNode] return !isSameDoorValue(currentValue, value) }) if (!hasChange) return @@ -137,7 +140,9 @@ export function DoorPanel() { useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId) } updateNode(selectedId as AnyNode['id'], updates) - useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + const scene = useScene.getState() + scene.dirtyNodes.add(selectedId as AnyNodeId) + if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) }, [selectedId, node, updateNode], ) @@ -348,7 +353,9 @@ export function DoorPanel() { const isRollupGarageDoor = doorType === 'garage-rollup' const isTiltupGarageDoor = doorType === 'garage-tiltup' const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door' - const supportsHandleSide = isSwingDoor + const supportsHingeSide = doorType === 'hinged' + const supportsHandleSide = doorType === 'hinged' + const supportsTopShape = !isGarageDoor const maxDoorWidth = isGarageDoor ? 6 : 3 const setOpeningTopRadius = (index: number, value: number, commit = false) => { @@ -395,6 +402,7 @@ export function DoorPanel() { handleSide: 'right', trackStyle: 'visible', operationState: Math.max(node.operationState ?? 0, 0.65), + threshold: false, contentPadding: [0.03, 0.04], segments: foldingDoorSegments, } @@ -411,6 +419,7 @@ export function DoorPanel() { trackStyle: 'pocket', slideDirection: node.slideDirection ?? 'left', operationState: node.operationState ?? 0, + threshold: false, contentPadding: [0.035, 0.045], segments: foldingDoorSegments, } @@ -427,6 +436,7 @@ export function DoorPanel() { trackStyle: 'visible', slideDirection: node.slideDirection ?? 'left', operationState: node.operationState ?? 0, + threshold: false, contentPadding: [0.035, 0.045], segments: foldingDoorSegments, } @@ -443,6 +453,7 @@ export function DoorPanel() { trackStyle: 'visible', slideDirection: node.slideDirection ?? 'left', operationState: node.operationState ?? 0, + threshold: false, contentPadding: [0.03, 0.04], segments: frenchDoorSegments, } @@ -456,6 +467,7 @@ export function DoorPanel() { ...dimensionUpdates, handle: false, threshold: false, + openingShape: 'rectangle', trackStyle: 'overhead', operationState: 0, garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)), @@ -472,6 +484,7 @@ export function DoorPanel() { ...dimensionUpdates, handle: false, threshold: false, + openingShape: 'rectangle', trackStyle: 'overhead', operationState: 0, garagePanelCount: 4, @@ -488,6 +501,7 @@ export function DoorPanel() { ...dimensionUpdates, handle: false, threshold: false, + openingShape: 'rectangle', trackStyle: 'overhead', operationState: 0, garagePanelCount: 4, @@ -738,7 +752,7 @@ export function DoorPanel() { />
- {!isOpening && ( + {!isOpening && supportsTopShape && (
-
- - Hinges Side - - handleUpdate({ hingesSide: v })} - options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, - ]} - value={node.hingesSide} - /> -
+ {supportsHingeSide && ( +
+ + Hinges Side + + handleUpdate({ hingesSide: v })} + options={[ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ]} + value={node.hingesSide} + /> +
+ )}
Direction diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index 32ee9d4d3..c61bdecfb 100755 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -16,7 +16,6 @@ import { cn } from '../../../lib/utils' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -81,14 +80,16 @@ const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] { label: 'Louvered', value: 'louvered' }, ] -const rectangleOnlyWindowTypes = new Set([ - 'sliding', - 'single-hung', - 'double-hung', - 'bay', - 'bow', +const shapedWindowTypes = new Set([ + 'fixed', + 'casement', + 'awning', + 'hopper', + 'louvered', ]) +const silllessWindowTypes = new Set(['bay', 'bow']) + export function WindowPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -110,14 +111,19 @@ export function WindowPanel() { const handleUpdate = useCallback( (updates: Partial) => { if (!(selectedId && node)) return + const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] + if (liveNode?.type !== 'window') return + const hasChange = Object.entries(updates).some(([key, value]) => { - const currentValue = node[key as keyof WindowNode] + const currentValue = liveNode[key as keyof WindowNode] return !isSameWindowValue(currentValue, value) }) if (!hasChange) return updateNode(selectedId as AnyNode['id'], updates) - useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + const scene = useScene.getState() + scene.dirtyNodes.add(selectedId as AnyNodeId) + if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) }, [selectedId, node, updateNode], ) @@ -316,6 +322,9 @@ export function WindowPanel() { node.windowType === 'single-hung' || node.windowType === 'double-hung' || node.windowType === 'louvered' + const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed') + const supportsGrid = node.windowType === 'fixed' + const supportsSill = !silllessWindowTypes.has(node.windowType) const setOperationState = (value: number) => { useInteractive.getState().cancelWindowAnimation(node.id) @@ -464,10 +473,10 @@ export function WindowPanel() { handleUpdate({ windowType: option.value, ...(option.value === 'awning' ? { awningDirection } : {}), - ...(rectangleOnlyWindowTypes.has(option.value) + ...(!shapedWindowTypes.has(option.value) ? { openingShape: 'rectangle' } : {}), - ...(option.value === 'bay' || option.value === 'bow' ? { sill: false } : {}), + ...(silllessWindowTypes.has(option.value) ? { sill: false } : {}), }) } type="button" @@ -597,7 +606,7 @@ export function WindowPanel() { /> - {!isOpening && !rectangleOnlyWindowTypes.has(node.windowType) && ( + {!isOpening && supportsWindowShape && ( @@ -814,128 +823,132 @@ export function WindowPanel() { /> - - { - const n = Math.max(1, Math.min(8, Math.round(v))) - handleUpdate({ columnRatios: Array(n).fill(1 / n) }) - }} - precision={0} - step={1} - value={numCols} - /> - { - const n = Math.max(1, Math.min(8, Math.round(v))) - handleUpdate({ rowRatios: Array(n).fill(1 / n) }) - }} - precision={0} - step={1} - value={numRows} - /> + {supportsGrid && ( + + { + const n = Math.max(1, Math.min(8, Math.round(v))) + handleUpdate({ columnRatios: Array(n).fill(1 / n) }) + }} + precision={0} + step={1} + value={numCols} + /> + { + const n = Math.max(1, Math.min(8, Math.round(v))) + handleUpdate({ rowRatios: Array(n).fill(1 / n) }) + }} + precision={0} + step={1} + value={numRows} + /> - {numCols > 1 && ( -
-
- Col Widths + {numCols > 1 && ( +
+
+ Col Widths +
+ {normCols.map((ratio, i) => ( + setColumnRatio(i, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(ratio * 100 * 10) / 10} + /> + ))} +
+ handleUpdate({ columnDividerThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} + /> +
- {normCols.map((ratio, i) => ( - setColumnRatio(i, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(ratio * 100 * 10) / 10} - /> - ))} -
+ )} + + {numRows > 1 && ( +
+
+ Row Heights +
+ {normRows.map((ratio, i) => ( + setRowRatio(i, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(ratio * 100 * 10) / 10} + /> + ))} +
+ handleUpdate({ rowDividerThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} + /> +
+
+ )} + + )} + + {supportsSill && ( + + handleUpdate({ sill: checked })} + /> + {node.sill && ( +
handleUpdate({ columnDividerThickness: v })} + label="Depth" + min={0} + onChange={(v) => handleUpdate({ sillDepth: v })} precision={3} step={0.01} unit="m" - value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} + value={Math.round(node.sillDepth * 1000) / 1000} /> -
-
- )} - - {numRows > 1 && ( -
-
- Row Heights -
- {normRows.map((ratio, i) => ( setRowRatio(i, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(ratio * 100 * 10) / 10} - /> - ))} -
- handleUpdate({ rowDividerThickness: v })} + label="Thickness" + min={0} + onChange={(v) => handleUpdate({ sillThickness: v })} precision={3} step={0.01} unit="m" - value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} + value={Math.round(node.sillThickness * 1000) / 1000} />
-
- )} - - - - handleUpdate({ sill: checked })} - /> - {node.sill && ( -
- handleUpdate({ sillDepth: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.sillDepth * 1000) / 1000} - /> - handleUpdate({ sillThickness: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.sillThickness * 1000) / 1000} - /> -
- )} -
+ )} + + )} )} From 2fc754108da06f51276b8d9ab72a430583702aa3 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 12 May 2026 09:49:10 +0530 Subject: [PATCH 7/9] Use community viewer toolbars in editor app --- apps/editor/app/page.tsx | 16 +- apps/editor/components/scene-loader.tsx | 7 +- apps/editor/components/toolbar-tooltip.tsx | 49 +++ apps/editor/components/viewer-toolbar.tsx | 362 +++++++++++++++++++++ apps/editor/package.json | 2 + bun.lock | 2 + 6 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 apps/editor/components/toolbar-tooltip.tsx create mode 100644 apps/editor/components/viewer-toolbar.tsx diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index 67924841d..80fb1a962 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,14 +1,12 @@ 'use client' -import { - Editor, - ItemsPanel, - type SidebarTab, - ViewerToolbarLeft, - ViewerToolbarRight, -} from '@pascal-app/editor' +import { Editor, ItemsPanel } from '@pascal-app/editor' import { Layers, Package, Settings } from 'lucide-react' import Link from 'next/link' +import { + CommunityViewerToolbarLeft, + CommunityViewerToolbarRight, +} from '@/components/viewer-toolbar' const SIDEBAR_TABS = [ { @@ -59,8 +57,8 @@ export default function Home() { layoutVersion="v2" projectId={PROJECT_ID} sidebarTabs={SIDEBAR_TABS} - viewerToolbarLeft={} - viewerToolbarRight={} + viewerToolbarLeft={} + viewerToolbarRight={} />
) diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 5546c032d..b3606f761 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -5,12 +5,11 @@ import { Editor, type SceneGraph, type SidebarTab, - ViewerToolbarLeft, - ViewerToolbarRight, } from '@pascal-app/editor' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' +import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar' export interface SceneMeta { id: string @@ -200,8 +199,8 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { onThumbnailCapture={handleThumb} projectId={meta.projectId ?? 'default'} sidebarTabs={SIDEBAR_TABS} - viewerToolbarLeft={} - viewerToolbarRight={} + viewerToolbarLeft={} + viewerToolbarRight={} />
) diff --git a/apps/editor/components/toolbar-tooltip.tsx b/apps/editor/components/toolbar-tooltip.tsx new file mode 100644 index 000000000..f3554d01a --- /dev/null +++ b/apps/editor/components/toolbar-tooltip.tsx @@ -0,0 +1,49 @@ +'use client' + +import * as TooltipPrimitive from '@radix-ui/react-tooltip' +import type * as React from 'react' +import { cn } from '@/lib/utils' + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 6, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx new file mode 100644 index 000000000..e3668ce28 --- /dev/null +++ b/apps/editor/components/viewer-toolbar.tsx @@ -0,0 +1,362 @@ +'use client' + +import { Icon as IconifyIcon } from '@iconify/react' +import { useEditor, useSidebarStore, type ViewMode } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { + ChevronsLeft, + ChevronsRight, + Columns2, + Eye, + EyeOff, + Footprints, + Grid2X2, + Moon, + Sun, +} from 'lucide-react' +import Image from 'next/image' +import { type ReactNode, useCallback } from 'react' +import { cn } from '@/lib/utils' +import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip' + +const TOOLBAR_CONTAINER = + 'inline-flex h-8 items-stretch overflow-hidden rounded-xl border border-border bg-background/90 shadow-2xl backdrop-blur-md' + +const TOOLBAR_BTN = + 'flex w-8 items-center justify-center text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90' + +function ToolbarTooltip({ children, label }: { children: ReactNode; label: string }) { + return ( + + {children} + {label} + + ) +} + +const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [ + { + id: '3d', + label: '3D', + icon: ( + + ), + }, + { + id: '2d', + label: '2D', + icon: ( + + ), + }, + { + id: 'split', + label: 'Split', + icon: , + }, +] + +const levelModeOrder = ['stacked', 'exploded', 'solo'] as const +const levelModeLabels: Record = { + manual: 'Stack', + stacked: 'Stack', + exploded: 'Exploded', + solo: 'Solo', +} + +const wallModeOrder = ['cutaway', 'up', 'down'] as const +const wallModeConfig: Record = { + up: { icon: '/icons/room.png', label: 'Full height' }, + cutaway: { icon: '/icons/wallcut.png', label: 'Cutaway' }, + down: { icon: '/icons/walllow.png', label: 'Low' }, +} + +function ViewModeControl() { + const viewMode = useEditor((state) => state.viewMode) + const setViewMode = useEditor((state) => state.setViewMode) + + return ( +
+ {VIEW_MODES.map((mode) => { + const isActive = viewMode === mode.id + return ( + + + + ) + })} +
+ ) +} + +function CollapseSidebarButton() { + const isCollapsed = useSidebarStore((state) => state.isCollapsed) + const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) + + const toggle = useCallback(() => { + setIsCollapsed(!isCollapsed) + }, [isCollapsed, setIsCollapsed]) + + return ( +
+ + + +
+ ) +} + +function LevelModeToggle() { + const levelMode = useViewer((state) => state.levelMode) + const setLevelMode = useViewer((state) => state.setLevelMode) + const isDefault = levelMode === 'stacked' || levelMode === 'manual' + + const cycle = () => { + if (levelMode === 'manual') { + setLevelMode('stacked') + return + } + + const index = levelModeOrder.indexOf(levelMode as (typeof levelModeOrder)[number]) + const next = levelModeOrder[(index + 1) % levelModeOrder.length] + if (next) setLevelMode(next) + } + + const label = `Levels: ${levelMode === 'manual' ? 'Manual' : (levelModeLabels[levelMode] ?? 'Stack')}` + + return ( + + + + ) +} + +function WallModeToggle() { + const wallMode = useViewer((state) => state.wallMode) + const setWallMode = useViewer((state) => state.setWallMode) + const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway! + + const cycle = () => { + const index = wallModeOrder.indexOf(wallMode as (typeof wallModeOrder)[number]) + const next = wallModeOrder[(index + 1) % wallModeOrder.length] + if (next) setWallMode(next) + } + + return ( + + + + ) +} + +function GridVisibilityToggle() { + const showGrid = useViewer((state) => state.showGrid) + const setShowGrid = useViewer((state) => state.setShowGrid) + + return ( + + + + ) +} + +function UnitToggle() { + const unit = useViewer((state) => state.unit) + const setUnit = useViewer((state) => state.setUnit) + + return ( + + + + ) +} + +function ThemeToggle() { + const theme = useViewer((state) => state.theme) + const setTheme = useViewer((state) => state.setTheme) + + return ( + + + + ) +} + +function CameraModeToggle() { + const cameraMode = useViewer((state) => state.cameraMode) + const setCameraMode = useViewer((state) => state.setCameraMode) + + return ( + + + + ) +} + +function WalkthroughButton() { + const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode) + const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode) + + return ( + + + + ) +} + +function PreviewButton() { + return ( + + + + ) +} + +export function CommunityViewerToolbarLeft() { + return ( + <> + + + + ) +} + +export function CommunityViewerToolbarRight() { + return ( +
+ + + +
+ + + +
+ + +
+ ) +} diff --git a/apps/editor/package.json b/apps/editor/package.json index 21e8dad85..f2c00a2ed 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -11,11 +11,13 @@ "check-types": "next typegen && tsc --noEmit" }, "dependencies": { + "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/viewer": "*", + "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.2.1", diff --git a/bun.lock b/bun.lock index 8a11d1a77..1e21227a6 100644 --- a/bun.lock +++ b/bun.lock @@ -26,11 +26,13 @@ "name": "editor", "version": "0.1.0", "dependencies": { + "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/viewer": "*", + "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.2.1", From f3444d4275ad63ef884fbad323d03da10179c1ad Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 12 May 2026 11:00:27 +0530 Subject: [PATCH 8/9] Add wall drag previews and fix move arrow handles --- .../editor/floating-action-menu.tsx | 6 +- .../editor/src/components/editor/index.tsx | 2 + .../editor/wall-move-side-handles.tsx | 259 +++++++++++++ .../components/tools/wall/move-wall-tool.tsx | 362 +++++++++++++++++- .../src/components/ui/panels/wall-panel.tsx | 98 +---- 5 files changed, 623 insertions(+), 104 deletions(-) create mode 100644 packages/editor/src/components/editor/wall-move-side-handles.tsx diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 2f696d275..93f81a5df 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -434,7 +434,11 @@ export function FloatingActionMenu() { ? handleDuplicate : undefined } - onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined} + onMove={ + node && node.type !== 'wall' && !DELETE_ONLY_TYPES.includes(node.type) + ? handleMove + : undefined + } onPointerDown={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 5e8930437..8515223cb 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -68,6 +68,7 @@ import { SiteEdgeLabels } from './site-edge-labels' import { SnapshotCaptureOverlay } from './snapshot-capture-overlay' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { WallMeasurementLabel } from './wall-measurement-label' +import { WallMoveSideHandles } from './wall-move-side-handles' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' const DELETE_CURSOR_BADGE_COLOR = '#ef4444' @@ -587,6 +588,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ <> {!isFirstPersonMode && } {!(isVersionPreviewMode || isFirstPersonMode) && } + {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } {!isFirstPersonMode && } diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx new file mode 100644 index 000000000..43bd9019d --- /dev/null +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -0,0 +1,259 @@ +'use client' + +import { + type AnyNodeId, + DEFAULT_WALL_HEIGHT, + getWallThickness, + sceneRegistry, + useScene, + type WallNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createPortal, type ThreeEvent } from '@react-three/fiber' +import { useEffect, useMemo, useState } from 'react' +import { + BufferGeometry, + ConeGeometry, + CylinderGeometry, + DoubleSide, + Float32BufferAttribute, + type Object3D, +} from 'three' +import { sfxEmitter } from '../../lib/sfx-bus' +import useEditor from '../../store/use-editor' + +const HANDLE_OFFSET = 0.42 +const HANDLE_MIN_OFFSET = 0.5 +const HANDLE_MIN_HEIGHT = 0.62 +const HANDLE_TOP_INSET = 0.08 +const ARROW_COLOR = '#8381ed' +const ARROW_HOVER_COLOR = '#a5b4fc' + +type WallMoveHandle = { + direction: [number, number] + key: string + position: [number, number, number] + rotationY: number +} + +function createArrowHandleGeometry() { + const shaft = new CylinderGeometry(0.04, 0.064, 0.25, 36) + const head = new ConeGeometry(0.13, 0.3, 48) + shaft.rotateZ(-Math.PI / 2) + shaft.translate(-0.085, 0, 0) + head.rotateZ(-Math.PI / 2) + head.translate(0.17, 0, 0) + + const positions: number[] = [] + const normals: number[] = [] + const uvs: number[] = [] + + for (const sourceGeometry of [shaft, head]) { + const geometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry + const position = geometry.getAttribute('position') + const normal = geometry.getAttribute('normal') + const uv = geometry.getAttribute('uv') + + for (let index = 0; index < position.count; index += 1) { + positions.push(position.getX(index), position.getY(index), position.getZ(index)) + normals.push(normal.getX(index), normal.getY(index), normal.getZ(index)) + uvs.push(uv?.getX(index) ?? 0, uv?.getY(index) ?? 0) + } + + if (geometry !== sourceGeometry) { + geometry.dispose() + } + sourceGeometry.dispose() + } + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) + geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2)) + geometry.computeVertexNormals() + geometry.computeBoundingSphere() + return geometry +} + +export function WallMoveSideHandles() { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const mode = useEditor((state) => state.mode) + const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) + const movingNode = useEditor((state) => state.movingNode) + const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) + const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) + const curvingWall = useEditor((state) => state.curvingWall) + const curvingFence = useEditor((state) => state.curvingFence) + + const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + const wall = useScene((state) => { + const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null + return node?.type === 'wall' ? node : null + }) + + const shouldRender = + Boolean(wall) && + !isFloorplanHovered && + mode !== 'delete' && + !movingNode && + !movingWallEndpoint && + !movingFenceEndpoint && + !curvingWall && + !curvingFence + + if (!shouldRender || !wall) return null + + return +} + +function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { + const [levelObject, setLevelObject] = useState(() => + wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null, + ) + + useEffect(() => { + let frameId = 0 + + const resolveLevelObject = () => { + const nextLevelObject = wall.parentId + ? (sceneRegistry.nodes.get(wall.parentId) ?? null) + : null + setLevelObject((currentLevelObject) => { + if (currentLevelObject === nextLevelObject) { + return currentLevelObject + } + return nextLevelObject + }) + + if (!nextLevelObject) { + frameId = window.requestAnimationFrame(resolveLevelObject) + } + } + + resolveLevelObject() + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId) + } + } + }, [wall.parentId]) + + const handles = useMemo(() => getWallMoveHandles(wall), [wall]) + + if (!levelObject || handles.length === 0) return null + + return createPortal( + + {handles.map((handle) => ( + + ))} + , + levelObject, + ) +} + +function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) { + const [isHovered, setIsHovered] = useState(false) + const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { + document.body.style.cursor = '' + } + } + }, []) + + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + + const activateWallMove = (event: ThreeEvent) => { + event.stopPropagation() + event.nativeEvent.preventDefault() + document.body.style.cursor = 'grabbing' + + sfxEmitter.emit('sfx:item-pick') + useEditor.getState().setMovingNode(wall) + useEditor.getState().setMovingWallEndpoint(null) + useEditor.getState().setMovingFenceEndpoint(null) + useEditor.getState().setCurvingWall(null) + useEditor.getState().setCurvingFence(null) + useViewer.getState().setSelection({ selectedIds: [] }) + } + + return ( + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') { + document.body.style.cursor = '' + } + }} + renderOrder={1002} + > + + + + + ) +} + +function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + + if (length < 1e-6) { + return [] + } + + const normal: [number, number] = [-dz / length, dx / length] + const midpoint: [number, number] = [ + (wall.start[0] + wall.end[0]) / 2, + (wall.start[1] + wall.end[1]) / 2, + ] + const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) + const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) + + return [ + buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), + buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), + ] +} + +function buildWallMoveHandle( + key: string, + midpoint: [number, number], + direction: [number, number], + offset: number, + height: number, +): WallMoveHandle { + return { + direction, + key, + position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset], + rotationY: Math.atan2(-direction[1], direction[0]), + } +} diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx index f19f8d08e..a52b2b5c4 100644 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-tool.tsx @@ -3,12 +3,19 @@ import { type AnyNodeId, constrainWallMoveDeltaToAxis, + DEFAULT_WALL_HEIGHT, + detectSpacesForLevel, emitter, type GridEvent, + getMaterialPresetByRef, getPerpendicularWallMoveAxis, + getRenderableSlabPolygon, pauseSceneHistory, planWallMoveJunctions, + resolveMaterial, resumeSceneHistory, + type SlabNode, + SlabNode as SlabSchema, useScene, type WallMoveAxis, type WallMoveBridgePlan, @@ -17,13 +24,18 @@ import { WallNode as WallSchema, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { BufferGeometry, DoubleSide, Float32BufferAttribute, ShapeUtils, Vector2 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting' +const AUTO_SLAB_PREVIEW_ELEVATION = 0.05 +const AUTO_SLAB_PREVIEW_Y = AUTO_SLAB_PREVIEW_ELEVATION + 0.025 + function rotateVector([x, z]: [number, number], angle: number): [number, number] { const cos = Math.cos(angle) const sin = Math.sin(angle) @@ -50,6 +62,19 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata' type LinkedWallSnapshot = WallNode +type GhostWallPreview = { + id: string + start: [number, number] + end: [number, number] + color: string + height: number +} + +type GhostSlabPreview = { + id: string + polygon: Array<[number, number]> +} + function getLinkedWallSnapshots(args: { wallId: WallNode['id'] wallParentId: string | null @@ -172,7 +197,11 @@ function getPlannedLinkedWallUpdates( ) } -function wallSegmentExists(walls: WallNode[], start: [number, number], end: [number, number]) { +function wallSegmentExists( + walls: Array>, + start: [number, number], + end: [number, number], +) { return walls.some( (wall) => (samePoint(wall.start, start) && samePoint(wall.end, end)) || @@ -180,6 +209,40 @@ function wallSegmentExists(walls: WallNode[], start: [number, number], end: [num ) } +function getWallGhostColor(wall: WallNode) { + const presetColor = + getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ?? + getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ?? + getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color + + if (presetColor) { + return presetColor + } + + return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color +} + +function getMinRotatedKey(values: string[]) { + if (values.length === 0) return '' + + let best = '' + for (let index = 0; index < values.length; index += 1) { + const value = [...values.slice(index), ...values.slice(0, index)].join('|') + if (!best || value < best) { + best = value + } + } + + return best +} + +function getPolygonPreviewKey(polygon: Array<[number, number]>) { + const values = polygon.map(([x, z]) => `${x.toFixed(3)}:${z.toFixed(3)}`) + const forward = getMinRotatedKey(values) + const reversed = getMinRotatedKey([...values].reverse()) + return forward < reversed ? forward : reversed +} + function getWallsAfterUpdates( nodes: ReturnType['nodes'], updates: Array<{ id: AnyNodeId; data: Partial }>, @@ -236,6 +299,240 @@ function buildBridgeWallCreates(args: { return creates } +function buildBridgeWallPreviews(args: { + bridgePlans: Array> + nextStart: [number, number] + nextEnd: [number, number] + existingWalls: WallNode[] +}): Array<{ ghost: GhostWallPreview; wall: WallNode }> { + const { bridgePlans, nextStart, nextEnd, existingWalls } = args + const wallsForDuplicateCheck: Array> = [...existingWalls] + const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = [] + + for (const plan of bridgePlans) { + const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd + + if (!isWallLongEnough(plan.originalPoint, nextPoint)) { + continue + } + + if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) { + continue + } + + const { id: _id, children: _children, ...sourceWall } = plan.wall + const wall = WallSchema.parse({ + ...sourceWall, + name: 'Wall Preview', + start: plan.originalPoint, + end: nextPoint, + children: [], + metadata: stripWallIsNewMetadata(plan.wall.metadata), + }) + const ghost = { + id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`, + start: [...plan.originalPoint] as [number, number], + end: [...nextPoint] as [number, number], + color: getWallGhostColor(plan.wall), + height: plan.wall.height ?? DEFAULT_WALL_HEIGHT, + } + previews.push({ ghost, wall }) + wallsForDuplicateCheck.push(wall) + } + + return previews +} + +function buildAutoSlabGhostPreviews(args: { + levelId: string + walls: WallNode[] + existingSlabs: SlabNode[] +}): GhostSlabPreview[] { + const { levelId, walls, existingSlabs } = args + const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId) + + if (levelWalls.length < 3) { + return [] + } + + const manualSlabKeys = new Set( + existingSlabs + .filter((slab) => !slab.autoFromWalls) + .map((slab) => getPolygonPreviewKey(slab.polygon)), + ) + const existingAutoKeys = new Set( + existingSlabs + .filter((slab) => slab.autoFromWalls) + .map((slab) => getPolygonPreviewKey(getRenderableSlabPolygon(slab))), + ) + const seenPreviewKeys = new Set() + const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls) + const previews: GhostSlabPreview[] = [] + + for (let index = 0; index < roomPolygons.length; index += 1) { + const polygon = roomPolygons[index] + if (!polygon || polygon.length < 3) continue + + const rawPolygon = polygon.map((point) => [point.x, point.y] as [number, number]) + if (manualSlabKeys.has(getPolygonPreviewKey(rawPolygon))) { + continue + } + + const previewSlab = SlabSchema.parse({ + polygon: rawPolygon, + holes: [], + elevation: AUTO_SLAB_PREVIEW_ELEVATION, + autoFromWalls: true, + }) + const renderablePolygon = getRenderableSlabPolygon(previewSlab) + const previewKey = getPolygonPreviewKey(renderablePolygon) + + if ( + renderablePolygon.length < 3 || + existingAutoKeys.has(previewKey) || + seenPreviewKeys.has(previewKey) + ) { + continue + } + + seenPreviewKeys.add(previewKey) + previews.push({ + id: `auto-slab:${index}:${previewKey}`, + polygon: renderablePolygon, + }) + } + + return previews +} + +function setPreviewGeometryAttributes( + geometry: BufferGeometry, + positions: number[], + normals: number[], + uvs: number[], +) { + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) + geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2)) +} + +function createWallPreviewGeometry(length: number, height: number) { + const geometry = new BufferGeometry() + setPreviewGeometryAttributes( + geometry, + [0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0], + [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], + [0, 0, 1, 0, 1, 1, 0, 1], + ) + geometry.setIndex([0, 1, 2, 0, 2, 3]) + geometry.computeBoundingSphere() + return geometry +} + +function createSlabPreviewGeometry(polygon: Array<[number, number]>) { + if (polygon.length < 3) { + return null + } + + const contour = polygon.map(([x, z]) => new Vector2(x, z)) + const triangles = ShapeUtils.triangulateShape(contour, []) + if (triangles.length === 0) { + return null + } + + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [x, z] of polygon) { + minX = Math.min(minX, x) + minZ = Math.min(minZ, z) + maxX = Math.max(maxX, x) + maxZ = Math.max(maxZ, z) + } + + const width = Math.max(maxX - minX, 0.001) + const depth = Math.max(maxZ - minZ, 0.001) + const positions: number[] = [] + const normals: number[] = [] + const uvs: number[] = [] + + for (const [x, z] of polygon) { + positions.push(x, 0, z) + normals.push(0, 1, 0) + uvs.push((x - minX) / width, (z - minZ) / depth) + } + + const geometry = new BufferGeometry() + setPreviewGeometryAttributes(geometry, positions, normals, uvs) + geometry.setIndex(triangles.flat()) + geometry.computeBoundingSphere() + return geometry +} + +function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) { + const dx = preview.end[0] - preview.start[0] + const dz = preview.end[1] - preview.start[1] + const length = Math.hypot(dx, dz) + const angle = -Math.atan2(dz, dx) + const geometry = useMemo(() => { + return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height) + }, [length, preview.height]) + + useEffect(() => () => geometry?.dispose(), [geometry]) + + if (!geometry) { + return null + } + + return ( + + + + + + + ) +} + +function GhostSlabPreviewMesh({ preview }: { preview: GhostSlabPreview }) { + const geometry = useMemo(() => createSlabPreviewGeometry(preview.polygon), [preview.polygon]) + + useEffect(() => () => geometry?.dispose(), [geometry]) + + if (!geometry) { + return null + } + + return ( + + + + + ) +} + export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const meta = typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) @@ -278,6 +575,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const centerZ = (node.start[1] + node.end[1]) / 2 return [centerX, 0, centerZ] }) + const [ghostWallPreviews, setGhostWallPreviews] = useState([]) + const [ghostSlabPreviews, setGhostSlabPreviews] = useState([]) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -323,8 +622,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { nextEnd, ) - const getLinkedPreviewUpdates = (nextStart: [number, number], nextEnd: [number, number]) => { - const plan = getMovePlan(nextStart, nextEnd) + const getLinkedPreviewUpdates = ( + plan: WallMoveJunctionPlan, + nextStart: [number, number], + nextEnd: [number, number], + ) => { const movedUpdates = getPlannedLinkedWallUpdates( plan, originalStart, @@ -344,13 +646,53 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const centerX = (nextStart[0] + nextEnd[0]) / 2 const centerZ = (nextStart[1] + nextEnd[1]) / 2 setCursorLocalPos([centerX, 0, centerZ]) - applyNodePreview([ + const previewPlan = getMovePlan(nextStart, nextEnd) + const previewUpdates = [ { id: nodeId, start: nextStart, end: nextEnd }, - ...getLinkedPreviewUpdates(nextStart, nextEnd), + ...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd), + ] + const previewCollapsedWallIds = new Set([ + ...previewUpdates + .filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end)) + .map((entry) => entry.id as AnyNodeId), + ...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId), ]) + const previewSceneWalls = getWallsAfterUpdates( + useScene.getState().nodes, + previewUpdates.map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), + ).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId)) + const bridgePreviews = buildBridgeWallPreviews({ + bridgePlans: previewPlan.bridgePlans, + nextStart, + nextEnd, + existingWalls: previewSceneWalls, + }) + const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost) + const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall) + const sceneNodes = useScene.getState().nodes + const levelId = node.parentId ?? null + setGhostWallPreviews(nextGhostWalls) + setGhostSlabPreviews( + levelId + ? buildAutoSlabGhostPreviews({ + levelId, + walls: [...previewSceneWalls, ...virtualBridgeWalls], + existingSlabs: Object.values(sceneNodes).filter( + (entry): entry is SlabNode => + entry?.type === 'slab' && (entry.parentId ?? null) === levelId, + ), + }) + : [], + ) + applyNodePreview(previewUpdates) } const restoreOriginal = () => { + setGhostWallPreviews([]) + setGhostSlabPreviews([]) applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, @@ -400,6 +742,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // Restore original baseline while paused so the next resume+update // registers as a single tracked change (undo reverts to original). + setGhostWallPreviews([]) + setGhostSlabPreviews([]) applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, @@ -534,6 +878,12 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { return ( + {ghostSlabPreviews.map((preview) => ( + + ))} + {ghostWallPreviews.map((preview) => ( + + ))} ) } diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 8ab8e6ac3..c1fae2e70 100644 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -4,60 +4,28 @@ import { type AnyNode, type AnyNodeId, getClampedWallCurveOffset, - getEffectiveWallSurfaceMaterial, getMaxWallCurveOffset, getWallCurveLength, - getWallSurfaceMaterialSignature, - type MaterialSchema, normalizeWallCurveOffset, useScene, type WallNode, - type WallSurfaceSide, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Move, Spline } from 'lucide-react' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' -function buildWallSurfaceMaterialPatch( - node: WallNode, - targetSide: WallSurfaceSide | null, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - const nextSurfaceMaterial = { material, materialPreset } - const nextInterior = - targetSide === null || targetSide === 'interior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'interior') - const nextExterior = - targetSide === null || targetSide === 'exterior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'exterior') - - return { - interiorMaterial: nextInterior.material, - interiorMaterialPreset: nextInterior.materialPreset, - exteriorMaterial: nextExterior.material, - exteriorMaterialPreset: nextExterior.materialPreset, - material: undefined, - materialPreset: undefined, - } -} - export function WallPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const setMovingNode = useEditor((s) => s.setMovingNode) const setCurvingWall = useEditor((s) => s.setCurvingWall) - const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, @@ -88,35 +56,6 @@ export function WallPanel() { [selectedId, updateNode], ) - const effectiveInteriorMaterial = useMemo( - () => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}), - [node], - ) - const effectiveExteriorMaterial = useMemo( - () => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}), - [node], - ) - const surfaceMaterialsMatch = useMemo( - () => - getWallSurfaceMaterialSignature(effectiveInteriorMaterial) === - getWallSurfaceMaterialSignature(effectiveExteriorMaterial), - [effectiveExteriorMaterial, effectiveInteriorMaterial], - ) - const materialTargetSide = - selectedMaterialTarget && - selectedMaterialTarget.nodeId === node?.id && - (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior') - ? selectedMaterialTarget.role - : null - const materialPickerValue = - materialTargetSide === 'interior' - ? effectiveInteriorMaterial - : materialTargetSide === 'exterior' - ? effectiveExteriorMaterial - : surfaceMaterialsMatch - ? effectiveInteriorMaterial - : {} - const handleUpdateLength = useCallback( (newLength: number) => { if (!node || newLength <= 0) return @@ -140,24 +79,6 @@ export function WallPanel() { [node, handleUpdate], ) - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - if (!(node && materialTargetSide)) return - handleUpdate( - buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset), - ) - }, - [handleUpdate, materialTargetSide, node], - ) - - const handleCustomMaterialChange = useCallback( - (material: MaterialSchema) => { - if (!(node && materialTargetSide)) return - handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined)) - }, - [handleUpdate, materialTargetSide, node], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -239,23 +160,6 @@ export function WallPanel() { )} - - {materialTargetSide ? null : ( -
- Click the wall face you want to edit. Materials now apply to one side at a time. -
- )} - -
- } label="Move" onClick={handleMove} /> From 70c85d7760c12eb40afc27f9b0d9da1b9f22f3db Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 12 May 2026 12:15:45 +0530 Subject: [PATCH 9/9] Refine wall move previews and expose auto-slab planning --- packages/core/src/index.ts | 2 + packages/core/src/lib/space-detection.ts | 39 ++- .../editor-2d/floorplan-action-menu-layer.tsx | 2 + .../src/components/editor/floorplan-panel.tsx | 68 +++- .../components/tools/wall/move-wall-tool.tsx | 296 +++++++----------- .../renderers/ceiling/ceiling-renderer.tsx | 27 +- .../renderers/slab/slab-renderer.tsx | 14 +- .../src/systems/ceiling/ceiling-system.tsx | 2 +- 8 files changed, 240 insertions(+), 210 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e147e18c1..bca40c4dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,6 +45,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon' export { detectSpacesForLevel, initSpaceDetectionSync, + planAutoSlabsForLevel, + type AutoSlabSyncPlan, type Space, wallTouchesOthers, } from './lib/space-detection' diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index f9fda76e0..94a6e47b7 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -41,6 +41,12 @@ type DetectedRoom = { bbox: ReturnType } +export type AutoSlabSyncPlan = { + create: SlabNodeType[] + update: Array<{ id: SlabNodeType['id']; data: Partial }> + delete: Array +} + const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const ROOM_CURVE_TOLERANCE = 0.04 @@ -488,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space { } } -function syncAutoSlabsForLevel( - levelId: string, +export function planAutoSlabsForLevel( roomPolygons: Point2D[][], existingSlabs: SlabNodeType[], - sceneStore: any, -) { +): AutoSlabSyncPlan { const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls) const manualSignatures = new Set( manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), @@ -618,16 +622,31 @@ function syncAutoSlabsForLevel( ) } - if (slabsToDelete.length > 0) { - sceneStore.getState().deleteNodes(slabsToDelete) + return { + create: slabsToCreate, + update: slabsToUpdate, + delete: slabsToDelete, + } +} + +function syncAutoSlabsForLevel( + levelId: string, + roomPolygons: Point2D[][], + existingSlabs: SlabNodeType[], + sceneStore: any, +) { + const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs) + + if (plan.delete.length > 0) { + sceneStore.getState().deleteNodes(plan.delete) } - if (slabsToUpdate.length > 0) { - sceneStore.getState().updateNodes(slabsToUpdate) + if (plan.update.length > 0) { + sceneStore.getState().updateNodes(plan.update) } - if (slabsToCreate.length > 0) { - sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId }))) + if (plan.create.length > 0) { + sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) } } diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index fefaf949a..6ad90876b 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -16,6 +16,7 @@ export type FloorplanActionMenuEntry = { onDelete: FloorplanActionMenuHandler onMove: FloorplanActionMenuHandler onAddHole?: FloorplanActionMenuHandler + onCurve?: FloorplanActionMenuHandler onDuplicate?: FloorplanActionMenuHandler } @@ -81,6 +82,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ > state.setPhase) const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint) const setMovingNode = useEditor((state) => state.setMovingNode) + const setCurvingWall = useEditor((state) => state.setCurvingWall) const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const structureLayer = useEditor((state) => state.structureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer) @@ -9324,6 +9326,7 @@ export function FloorplanPanel() { selectedWallEntry, wallCurveDraft, ]) + const canCurveSelectedWall = wallCurveHandles.length > 0 const slabVertexHandles = useMemo(() => { if (!shouldShowSlabBoundaryHandles) { return [] @@ -14066,6 +14069,57 @@ export function FloorplanPanel() { }, [selectedWallEntry, setMovingNode, setSelection], ) + const duplicateSelectedWall = useCallback(() => { + const wall = selectedWallEntry?.wall + if (!wall?.parentId) { + return + } + + sfxEmitter.emit('sfx:item-pick') + + const cloned = structuredClone(wall) as Record + delete cloned.id + cloned.children = [] + cloned.metadata = { + ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), + isNew: true, + } + + const temporal = useScene.temporal.getState() + temporal.pause() + try { + const duplicate = WallNodeSchema.parse(cloned) + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + setMovingNode(duplicate) + setSelection({ selectedIds: [] }) + } catch (error) { + console.error('Failed to duplicate wall', error) + } finally { + temporal.resume() + } + }, [selectedWallEntry, setMovingNode, setSelection]) + const handleSelectedWallDuplicate = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + duplicateSelectedWall() + }, + [duplicateSelectedWall], + ) + const handleSelectedWallCurve = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const wall = selectedWallEntry?.wall + if (!(wall && canCurveSelectedWall)) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setCurvingWall(wall) + setSelection({ selectedIds: [] }) + }, + [canCurveSelectedWall, selectedWallEntry, setCurvingWall, setSelection], + ) const handleSelectedWallDelete = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -16105,9 +16159,17 @@ export function FloorplanPanel() { site, ]) const hasDuplicatableFloorplanSelection = Boolean( - selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry, + selectedItemEntry || + selectedOpeningEntry || + selectedStairEntry || + selectedRoofEntry || + selectedWallEntry, ) const handleDuplicateFloorplanSelection = useCallback(() => { + if (selectedWallEntry) { + duplicateSelectedWall() + return + } if (selectedOpeningEntry) { duplicateSelectedOpening() return @@ -16124,6 +16186,7 @@ export function FloorplanPanel() { duplicateSelectedRoof() } }, [ + duplicateSelectedWall, duplicateSelectedItem, duplicateSelectedOpening, duplicateSelectedRoof, @@ -16132,6 +16195,7 @@ export function FloorplanPanel() { selectedOpeningEntry, selectedRoofEntry, selectedStairEntry, + selectedWallEntry, ]) const activeDraftAnchorPoint = referenceScaleDraft?.start ?? @@ -16258,7 +16322,9 @@ export function FloorplanPanel() { }} wall={{ position: selectedWallActionMenuPosition, + onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined, onDelete: handleSelectedWallDelete, + onDuplicate: handleSelectedWallDuplicate, onMove: handleSelectedWallMove, }} /> diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx index a52b2b5c4..36cc061b5 100644 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-tool.tsx @@ -9,13 +9,12 @@ import { type GridEvent, getMaterialPresetByRef, getPerpendicularWallMoveAxis, - getRenderableSlabPolygon, pauseSceneHistory, + planAutoSlabsForLevel, planWallMoveJunctions, resolveMaterial, resumeSceneHistory, type SlabNode, - SlabNode as SlabSchema, useScene, type WallMoveAxis, type WallMoveBridgePlan, @@ -25,7 +24,7 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { BufferGeometry, DoubleSide, Float32BufferAttribute, ShapeUtils, Vector2 } from 'three' +import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -33,9 +32,6 @@ import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting' -const AUTO_SLAB_PREVIEW_ELEVATION = 0.05 -const AUTO_SLAB_PREVIEW_Y = AUTO_SLAB_PREVIEW_ELEVATION + 0.025 - function rotateVector([x, z]: [number, number], angle: number): [number, number] { const cos = Math.cos(angle) const sin = Math.sin(angle) @@ -70,11 +66,6 @@ type GhostWallPreview = { height: number } -type GhostSlabPreview = { - id: string - polygon: Array<[number, number]> -} - function getLinkedWallSnapshots(args: { wallId: WallNode['id'] wallParentId: string | null @@ -222,27 +213,6 @@ function getWallGhostColor(wall: WallNode) { return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color } -function getMinRotatedKey(values: string[]) { - if (values.length === 0) return '' - - let best = '' - for (let index = 0; index < values.length; index += 1) { - const value = [...values.slice(index), ...values.slice(0, index)].join('|') - if (!best || value < best) { - best = value - } - } - - return best -} - -function getPolygonPreviewKey(polygon: Array<[number, number]>) { - const values = polygon.map(([x, z]) => `${x.toFixed(3)}:${z.toFixed(3)}`) - const forward = getMinRotatedKey(values) - const reversed = getMinRotatedKey([...values].reverse()) - return forward < reversed ? forward : reversed -} - function getWallsAfterUpdates( nodes: ReturnType['nodes'], updates: Array<{ id: AnyNodeId; data: Partial }>, @@ -257,6 +227,32 @@ function getWallsAfterUpdates( }) } +function cloneSlabSnapshot(slab: SlabNode): SlabNode { + return { + ...slab, + polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]), + holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), + holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })), + } +} + +function getLevelSlabs(levelId: string, nodes: ReturnType['nodes']) { + return Object.values(nodes).filter( + (entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId, + ) +} + +function getLevelAutoSlabs( + levelId: string, + nodes: ReturnType['nodes'], +) { + return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls) +} + +function getLevelAutoSlabSnapshots(levelId: string) { + return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot) +} + function buildBridgeWallCreates(args: { bridgePlans: Array> nextStart: [number, number] @@ -343,68 +339,6 @@ function buildBridgeWallPreviews(args: { return previews } -function buildAutoSlabGhostPreviews(args: { - levelId: string - walls: WallNode[] - existingSlabs: SlabNode[] -}): GhostSlabPreview[] { - const { levelId, walls, existingSlabs } = args - const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId) - - if (levelWalls.length < 3) { - return [] - } - - const manualSlabKeys = new Set( - existingSlabs - .filter((slab) => !slab.autoFromWalls) - .map((slab) => getPolygonPreviewKey(slab.polygon)), - ) - const existingAutoKeys = new Set( - existingSlabs - .filter((slab) => slab.autoFromWalls) - .map((slab) => getPolygonPreviewKey(getRenderableSlabPolygon(slab))), - ) - const seenPreviewKeys = new Set() - const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls) - const previews: GhostSlabPreview[] = [] - - for (let index = 0; index < roomPolygons.length; index += 1) { - const polygon = roomPolygons[index] - if (!polygon || polygon.length < 3) continue - - const rawPolygon = polygon.map((point) => [point.x, point.y] as [number, number]) - if (manualSlabKeys.has(getPolygonPreviewKey(rawPolygon))) { - continue - } - - const previewSlab = SlabSchema.parse({ - polygon: rawPolygon, - holes: [], - elevation: AUTO_SLAB_PREVIEW_ELEVATION, - autoFromWalls: true, - }) - const renderablePolygon = getRenderableSlabPolygon(previewSlab) - const previewKey = getPolygonPreviewKey(renderablePolygon) - - if ( - renderablePolygon.length < 3 || - existingAutoKeys.has(previewKey) || - seenPreviewKeys.has(previewKey) - ) { - continue - } - - seenPreviewKeys.add(previewKey) - previews.push({ - id: `auto-slab:${index}:${previewKey}`, - polygon: renderablePolygon, - }) - } - - return previews -} - function setPreviewGeometryAttributes( geometry: BufferGeometry, positions: number[], @@ -430,48 +364,6 @@ function createWallPreviewGeometry(length: number, height: number) { return geometry } -function createSlabPreviewGeometry(polygon: Array<[number, number]>) { - if (polygon.length < 3) { - return null - } - - const contour = polygon.map(([x, z]) => new Vector2(x, z)) - const triangles = ShapeUtils.triangulateShape(contour, []) - if (triangles.length === 0) { - return null - } - - let minX = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY - - for (const [x, z] of polygon) { - minX = Math.min(minX, x) - minZ = Math.min(minZ, z) - maxX = Math.max(maxX, x) - maxZ = Math.max(maxZ, z) - } - - const width = Math.max(maxX - minX, 0.001) - const depth = Math.max(maxZ - minZ, 0.001) - const positions: number[] = [] - const normals: number[] = [] - const uvs: number[] = [] - - for (const [x, z] of polygon) { - positions.push(x, 0, z) - normals.push(0, 1, 0) - uvs.push((x - minX) / width, (z - minZ) / depth) - } - - const geometry = new BufferGeometry() - setPreviewGeometryAttributes(geometry, positions, normals, uvs) - geometry.setIndex(triangles.flat()) - geometry.computeBoundingSphere() - return geometry -} - function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) { const dx = preview.end[0] - preview.start[0] const dz = preview.end[1] - preview.start[1] @@ -504,35 +396,6 @@ function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) { ) } -function GhostSlabPreviewMesh({ preview }: { preview: GhostSlabPreview }) { - const geometry = useMemo(() => createSlabPreviewGeometry(preview.polygon), [preview.polygon]) - - useEffect(() => () => geometry?.dispose(), [geometry]) - - if (!geometry) { - return null - } - - return ( - - - - - ) -} - export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const meta = typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) @@ -564,6 +427,9 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { originalEnd: node.end, }), ) + const originalAutoSlabsRef = useRef( + node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [], + ) const dragAnchorRef = useRef<[number, number] | null>(null) const nodeIdRef = useRef(node.id) const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) @@ -576,7 +442,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { return [centerX, 0, centerZ] }) const [ghostWallPreviews, setGhostWallPreviews] = useState([]) - const [ghostSlabPreviews, setGhostSlabPreviews] = useState([]) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -588,9 +453,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const originalEnd = originalEndRef.current const originalCenter = originalCenterRef.current const originalHalfVector = originalHalfVectorRef.current + const levelId = node.parentId ?? null + const originalAutoSlabs = originalAutoSlabsRef.current pauseSceneHistory(useScene) - let wasCommitted = false + let shouldRestoreOnCleanup = true const applyNodePreview = ( updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>, @@ -606,6 +473,72 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } } + const applyLiveAutoSlabPreview = (walls: WallNode[]) => { + if (!levelId) { + return + } + + const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId) + const sceneState = useScene.getState() + const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls) + const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes)) + + if ( + slabPlan.create.length === 0 && + slabPlan.update.length === 0 && + slabPlan.delete.length === 0 + ) { + return + } + + sceneState.applyNodeChanges({ + update: slabPlan.update.map((entry) => ({ + id: entry.id as AnyNodeId, + data: entry.data, + })), + create: slabPlan.create.map((slab) => ({ + node: slab, + parentId: levelId as AnyNodeId, + })), + delete: slabPlan.delete.map((id) => id as AnyNodeId), + }) + } + + const restoreAutoSlabPreview = () => { + if (!levelId) { + return + } + + const sceneState = useScene.getState() + const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id)) + const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes) + const update = originalAutoSlabs + .filter((slab) => sceneState.nodes[slab.id as AnyNodeId]) + .map((slab) => ({ + id: slab.id as AnyNodeId, + data: cloneSlabSnapshot(slab), + })) + const create = originalAutoSlabs + .filter((slab) => !sceneState.nodes[slab.id as AnyNodeId]) + .map((slab) => ({ + node: cloneSlabSnapshot(slab), + parentId: levelId as AnyNodeId, + })) + const deleteIds = currentAutoSlabs + .filter((slab) => !originalIds.has(slab.id)) + .map((slab) => slab.id as AnyNodeId) + + if (update.length === 0 && create.length === 0 && deleteIds.length === 0) { + return + } + + sceneState.applyNodeChanges({ + update, + create, + delete: deleteIds, + }) + } + const buildWallFromCenter = (center: [number, number]) => { const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current) const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]] @@ -672,31 +605,18 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { }) const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost) const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall) - const sceneNodes = useScene.getState().nodes - const levelId = node.parentId ?? null setGhostWallPreviews(nextGhostWalls) - setGhostSlabPreviews( - levelId - ? buildAutoSlabGhostPreviews({ - levelId, - walls: [...previewSceneWalls, ...virtualBridgeWalls], - existingSlabs: Object.values(sceneNodes).filter( - (entry): entry is SlabNode => - entry?.type === 'slab' && (entry.parentId ?? null) === levelId, - ), - }) - : [], - ) applyNodePreview(previewUpdates) + applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls]) } const restoreOriginal = () => { setGhostWallPreviews([]) - setGhostSlabPreviews([]) applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, ]) + restoreAutoSlabPreview() } const onGridMove = (event: GridEvent) => { @@ -738,16 +658,16 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const preview = previewRef.current ?? { start: originalStart, end: originalEnd } - wasCommitted = true + shouldRestoreOnCleanup = false // Restore original baseline while paused so the next resume+update // registers as a single tracked change (undo reverts to original). setGhostWallPreviews([]) - setGhostSlabPreviews([]) applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, ]) + restoreAutoSlabPreview() resumeSceneHistory(useScene) const commitPlan = getMovePlan(preview.start, preview.end) @@ -848,6 +768,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onCancel = () => { + shouldRestoreOnCleanup = false restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) resumeSceneHistory(useScene) @@ -862,7 +783,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { window.addEventListener('keyup', onKeyUp) return () => { - if (!wasCommitted) { + if (shouldRestoreOnCleanup) { restoreOriginal() } shiftPressedRef.current = false @@ -873,14 +794,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, [exitMoveMode, isNew, node.metadata]) + }, [exitMoveMode, isNew, node.metadata, node.parentId]) return ( - {ghostSlabPreviews.map((preview) => ( - - ))} {ghostWallPreviews.map((preview) => ( ))} diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 91e7487c5..2bf712e0c 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -4,12 +4,19 @@ import { resolveMaterial, useRegistry, } from '@pascal-app/core' -import { useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' +import { BufferGeometry, Float32BufferAttribute } from 'three' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { NodeRenderer } from '../node-renderer' +function createEmptyGeometry() { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([], 3)) + return geometry +} + const gridScale = 5 const gridX = positionWorld.x.mul(gridScale).fract() const gridY = positionWorld.z.mul(gridScale).fract() @@ -51,10 +58,20 @@ function getCeilingMaterials(color = '#999999') { export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) + const placeholderGeometry = useMemo(createEmptyGeometry, []) + const gridPlaceholderGeometry = useMemo(createEmptyGeometry, []) useRegistry(node.id, 'ceiling', ref) const handlers = useNodeEvents(node, 'ceiling') + useEffect( + () => () => { + placeholderGeometry.dispose() + gridPlaceholderGeometry.dispose() + }, + [gridPlaceholderGeometry, placeholderGeometry], + ) + const materials = useMemo(() => { const preset = getMaterialPresetByRef(node.materialPreset) const props = preset?.mapProperties ?? resolveMaterial(node.material) @@ -69,17 +86,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { ]) return ( - - + - - + /> {node.children.map((childId) => ( ))} diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index f59431e64..330c5d1b5 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -11,6 +11,12 @@ import { const slabMaterialCache = new Map() +function createEmptyGeometry() { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + return geometry +} + function getSlabMaterial( cacheKey: string, params: { material?: SlabNode['material']; materialPreset?: string }, @@ -47,11 +53,14 @@ function getSlabMaterial( export const SlabRenderer = ({ node }: { node: SlabNode }) => { const ref = useRef(null!) + const placeholderGeometry = useMemo(createEmptyGeometry, []) useRegistry(node.id, 'slab', ref) const handlers = useNodeEvents(node, 'slab') + useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry]) + const material = useMemo(() => { const resolvedMaterial = node.material const resolvedMaterialPreset = node.materialPreset @@ -75,13 +84,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { return ( - - + /> ) } diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 82f06e797..c15d15258 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -50,7 +50,7 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) { const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh if (gridMesh) { gridMesh.geometry.dispose() - gridMesh.geometry = newGeo + gridMesh.geometry = newGeo.clone() } // Position at the ceiling height