From f84910848b52d69148585a2f0f8209da746285fd Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 7 Jun 2026 00:24:15 -0400 Subject: [PATCH] Improve roof node editing --- packages/core/src/registry/handles.ts | 4 +- packages/core/src/schema/index.ts | 1 + .../core/src/schema/nodes/roof-segment.ts | 37 ++++++++- .../editor/handles/use-handle-drag.ts | 10 ++- .../components/editor/node-arrow-handles.tsx | 1 + .../components/editor/selection-manager.tsx | 72 ++++++++++++++++- .../src/components/tools/roof/roof-tool.tsx | 13 ++- packages/nodes/src/roof/definition.ts | 79 ++++++++++++++++++- packages/nodes/src/roof/renderer.tsx | 8 +- packages/nodes/src/shared/roof-segment-hit.ts | 40 +--------- packages/nodes/src/shared/roof-surface.ts | 23 +----- 11 files changed, 216 insertions(+), 72 deletions(-) diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 154e7b4c..62ffc589 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -316,7 +316,9 @@ export type TapActionHandle = { * the hit into the node's parent-local frame, and reports the new local XZ * (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release * with the same live-override → commit-on-release flow as the resize / rotate - * handles. Rendered as a 4-way cross of double-headed arrows. + * handles. Rendered as a 4-way cross of double-headed arrows. Pure translation + * does not require geometry dirtying; renderers consume the live position + * override directly. */ export type TranslateHandle = { kind: 'translate' diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index c7ec64b5..777f3456 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -101,6 +101,7 @@ export { getActiveRoofHeight, getEffectiveSegmentSurfaceMaterial, getPitchFromActiveRoofHeight, + getRoofSegmentSurfaceY, getSegmentSlopeFrame, hasSegmentMaterialOverride, ROOF_SHAPE_DEFAULTS, diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index d6e8f56b..201f5ed0 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -181,7 +181,6 @@ function getPrimarySlopeRun(input: PitchInputs & ShapeRatios): number { return min * input.mansardSteepWidthRatio case 'dutch': return min * input.dutchHipWidthRatio - case 'hip': default: return min / 2 } @@ -253,6 +252,42 @@ export function getActiveRoofHeight(node: Parameters & + Parameters[0], + localX: number, + localZ: number, +): number { + const activeRh = getActiveRoofHeight(node) + const peakY = node.wallHeight + activeRh + if (activeRh === 0) return node.wallHeight + + if ( + node.roofType === 'gable' || + node.roofType === 'gambrel' || + node.roofType === 'mansard' || + node.roofType === 'dutch' + ) { + const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 + return peakY - t * activeRh + } + + if (node.roofType === 'shed') { + const t = (localZ + node.depth / 2) / (node.depth || 1) + return peakY - t * activeRh + } + + if (node.roofType === 'hip') { + const fx = node.width > 0 ? Math.abs(localX) / (node.width / 2) : 0 + const fz = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 + return peakY - Math.max(fx, fz) * activeRh + } + + const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 + return peakY - t * activeRh +} + /** * Inverse of `getActiveRoofHeight` — recover the pitch a legacy * `roofHeight` value would correspond to. Used by the scene migration. diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index 3be0748b..574e95ba 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -44,6 +44,7 @@ export type HandleDragMoveContext = { type HandleDragSession = { move: (context: HandleDragMoveContext) => Partial | null + markDirty?: boolean onBegin?: () => void onEnd?: () => void overrideId?: AnyNodeId @@ -122,6 +123,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { if (!session) return const overrideId = session.overrideId ?? nodeId + const markDirty = session.markDirty !== false document.body.style.cursor = cursor sfxEmitter.emit('sfx:item-pick') useViewer.getState().setInputDragging(true) @@ -137,7 +139,9 @@ export function useHandleDrag(args: UseHandleDragArgs) { if (!patch) return lastPatch = patch useLiveNodeOverrides.getState().set(overrideId, patch as Record) - useScene.getState().markDirty(overrideId) + if (markDirty) { + useScene.getState().markDirty(overrideId) + } } const cleanup = () => { @@ -157,7 +161,9 @@ export function useHandleDrag(args: UseHandleDragArgs) { const clearOverride = () => { useLiveNodeOverrides.getState().clear(overrideId) - useScene.getState().markDirty(overrideId) + if (markDirty) { + useScene.getState().markDirty(overrideId) + } } const onUp = () => { diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 869841dc..49463f80 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -1185,6 +1185,7 @@ function TranslateArrow({ .position ?? [0, 0, 0] return { + markDirty: false, move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { const hit = new Vector3() if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index f7811948..8cdbc2ef 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -9,6 +9,7 @@ import { getEffectiveRoofSurfaceMaterial, getEffectiveSegmentSurfaceMaterial, getMaterialPresetByRef, + getRoofSegmentSurfaceY, getSelectableKinds, type ItemNode, isRegistrySelectable, @@ -40,7 +41,7 @@ import { useViewer, } from '@pascal-app/viewer' import { useCallback, useEffect, useRef } from 'react' -import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three' +import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three' import { type ActivePaintMaterial, buildRoofSegmentSurfaceMaterialPatch, @@ -205,6 +206,59 @@ function getRegisteredMesh(nodeId: string): Mesh | null { return object && (object as Mesh).isMesh ? (object as Mesh) : null } +const roofSelectionWorldPoint = new Vector3() + +function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null { + const roof = event.node + if (roof.type !== 'roof') return null + + roofSelectionWorldPoint.set(...event.position) + const nodes = useScene.getState().nodes + let firstSegment: RoofSegmentNode | null = null + let bestSegment: { node: RoofSegmentNode; score: number } | null = null + + for (const childId of roof.children ?? []) { + const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined + if (segment?.type !== 'roof-segment') continue + + const object = getRegisteredNodeObject(segment.id) + if (!object) continue + + if (!firstSegment) firstSegment = segment + + object.updateWorldMatrix(true, false) + const local = object.worldToLocal(roofSelectionWorldPoint.clone()) + const overhang = segment.overhang ?? 0 + const halfWidth = segment.width / 2 + overhang + const halfDepth = segment.depth / 2 + overhang + + if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) { + continue + } + + const score = Math.abs(local.y - getRoofSegmentSurfaceY(segment, local.x, local.z)) + if (!bestSegment || score < bestSegment.score) { + bestSegment = { node: segment, score } + } + } + + return bestSegment?.node ?? firstSegment +} + +function isInActiveRoofContext( + segment: RoofSegmentNode, + selectedIds: readonly string[], + nodes: Record, +): boolean { + if (!segment.parentId) return false + if (selectedIds.includes(segment.id) || selectedIds.includes(segment.parentId)) return true + + return selectedIds.some((selectedId) => { + const selectedNode = nodes[selectedId] + return selectedNode?.type === 'roof-segment' && selectedNode.parentId === segment.parentId + }) +} + function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup { const previousMaterial = mesh.material mesh.material = material @@ -1251,8 +1305,14 @@ export const SelectionManager = () => { let nodeToSelect = node if (node.type === 'roof-segment' && node.parentId) { - const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] - if (parentNode && parentNode.type === 'roof') { + const nodes = useScene.getState().nodes + const parentNode = nodes[node.parentId as AnyNodeId] + const selectedIds = useViewer.getState().selection.selectedIds + if ( + parentNode && + parentNode.type === 'roof' && + !isInActiveRoofContext(node, selectedIds, nodes) + ) { nodeToSelect = parentNode } } @@ -1439,7 +1499,11 @@ export const SelectionManager = () => { } const onDoubleClick = (event: NodeEvent) => { - const node = event.node + let node = event.node + if (node.type === 'roof') { + node = resolveRoofSegmentSelectionTarget(event) ?? node + } + const currentPhase = useEditor.getState().phase let targetPhase: 'site' | 'structure' | 'furnish' | null = null diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 773aa396..982854ee 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -9,6 +9,7 @@ import { RoofSegmentNode, resolveAlignment, sceneRegistry, + snapScalar, useAlignmentGuides, useScene, } from '@pascal-app/core' @@ -28,6 +29,10 @@ const GRID_OFFSET = 0.02 /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 +function snapToActiveGrid(value: number): number { + return snapScalar(value, useEditor.getState().gridSnapStep) +} + /** * Creates a roof group with one default gable segment */ @@ -233,8 +238,8 @@ export const RoofTool: React.FC = () => { if (!cursorRef.current) return const [gridX, gridZ] = alignPoint( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, + snapToActiveGrid(event.localPosition[0]), + snapToActiveGrid(event.localPosition[2]), event.localPosition[0], event.localPosition[2], event.nativeEvent?.altKey === true, @@ -271,8 +276,8 @@ export const RoofTool: React.FC = () => { if (!currentLevelId) return const [gridX, gridZ] = alignPoint( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, + snapToActiveGrid(event.localPosition[0]), + snapToActiveGrid(event.localPosition[2]), event.localPosition[0], event.localPosition[2], event.nativeEvent?.altKey === true, diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index 8f3663b4..bd86a393 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -1,8 +1,84 @@ -import { type NodeDefinition, RoofNode as RoofNodeSchema } from '@pascal-app/core' +import { + type AnyNodeId, + type HandleDescriptor, + type NodeDefinition, + RoofNode as RoofNodeSchema, + type RoofNode as RoofNodeType, + type RoofSegmentNode, + type SceneApi, +} from '@pascal-app/core' import { buildRoofFloorplan } from './floorplan' import { roofParametrics } from './parametrics' import { RoofNode } from './schema' +const MOVE_FRONT_OFFSET = 0.35 +const MIN_ROOF_FOOTPRINT = 1 + +type RoofFootprintBounds = { + maxX: number + maxZ: number + minX: number + minZ: number +} + +function getRoofFootprintBounds(node: RoofNodeType, sceneApi: SceneApi): RoofFootprintBounds { + let bounds: RoofFootprintBounds | null = null + + for (const childId of node.children ?? []) { + const segment = sceneApi.get(childId as AnyNodeId) + if (segment?.type !== 'roof-segment') continue + + const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2 + const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2 + const cos = Math.cos(segment.rotation ?? 0) + const sin = Math.sin(segment.rotation ?? 0) + const corners = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] as const + + for (const [x, z] of corners) { + const localX = segment.position[0] + x * cos + z * sin + const localZ = segment.position[2] - x * sin + z * cos + bounds = + bounds === null + ? { maxX: localX, maxZ: localZ, minX: localX, minZ: localZ } + : { + maxX: Math.max(bounds.maxX, localX), + maxZ: Math.max(bounds.maxZ, localZ), + minX: Math.min(bounds.minX, localX), + minZ: Math.min(bounds.minZ, localZ), + } + } + } + + return bounds ?? { maxX: 0.5, maxZ: 0.5, minX: -0.5, minZ: -0.5 } +} + +function roofMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + position: (node, sceneApi) => { + const bounds = getRoofFootprintBounds(node, sceneApi) + return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + MOVE_FRONT_OFFSET] + }, + }, + apply: (_node, position) => ({ position: [position[0], position[1], position[2]] }), + snapExtents: (node, sceneApi) => { + const bounds = getRoofFootprintBounds(node, sceneApi) + const width = Math.max(bounds.maxX - bounds.minX, MIN_ROOF_FOOTPRINT) + const depth = Math.max(bounds.maxZ - bounds.minZ, MIN_ROOF_FOOTPRINT) + const swap = Math.abs(Math.sin(node.rotation ?? 0)) > 0.9 + return [swap ? depth : width, swap ? width : depth] + }, + } +} + +const roofHandles: HandleDescriptor[] = [roofMoveHandle()] + /** * Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer` * + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` + @@ -43,6 +119,7 @@ export const roofDefinition: NodeDefinition = { }, parametrics: roofParametrics, + handles: roofHandles, floorplan: buildRoofFloorplan, renderer: { diff --git a/packages/nodes/src/roof/renderer.tsx b/packages/nodes/src/roof/renderer.tsx index a09878bd..dd68f359 100644 --- a/packages/nodes/src/roof/renderer.tsx +++ b/packages/nodes/src/roof/renderer.tsx @@ -5,6 +5,7 @@ import { hasSegmentMaterialOverride, type RoofNode, type RoofSegmentNode, + useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' @@ -14,8 +15,13 @@ import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials' -export const RoofRenderer = ({ node }: { node: RoofNode }) => { +export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => { const ref = useRef(null!) + const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id)) + const node = useMemo( + () => (liveOverride ? ({ ...rawNode, ...liveOverride } as RoofNode) : rawNode), + [rawNode, liveOverride], + ) useRegistry(node.id, 'roof', ref) useLayoutEffect(() => { diff --git a/packages/nodes/src/shared/roof-segment-hit.ts b/packages/nodes/src/shared/roof-segment-hit.ts index 82b41790..262c847c 100644 --- a/packages/nodes/src/shared/roof-segment-hit.ts +++ b/packages/nodes/src/shared/roof-segment-hit.ts @@ -1,6 +1,6 @@ import { type AnyNodeId, - getActiveRoofHeight, + getRoofSegmentSurfaceY, type RoofNode, type RoofSegmentNode, sceneRegistry, @@ -17,40 +17,6 @@ export type RoofSegmentHit = { localZ: number } -/** - * Analytical surface Y for `seg` at segment-local (lx, lz). Mirrors - * the per-roof-type slope math in `shared/roof-surface.ts` so the - * disambiguator below stays free of cross-kind imports. Returns the - * roof's local surface height; the value is only used to compare - * candidates, never written to the scene. - */ -function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): number { - const rh = getActiveRoofHeight(seg) - const peakY = seg.wallHeight + rh - if (rh === 0) return seg.wallHeight - - if ( - seg.roofType === 'gable' || - seg.roofType === 'gambrel' || - seg.roofType === 'mansard' || - seg.roofType === 'dutch' - ) { - const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0 - return peakY - t * rh - } - if (seg.roofType === 'shed') { - const t = (lz + seg.depth / 2) / (seg.depth || 1) - return peakY - t * rh - } - if (seg.roofType === 'hip') { - const fx = seg.width > 0 ? Math.abs(lx) / (seg.width / 2) : 0 - const fz = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0 - return peakY - Math.max(fx, fz) * rh - } - const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0 - return peakY - t * rh -} - /** * Resolve which roof-segment the user clicked. Used by every placement * tool that drops a new node onto a roof (box-vent, ridge-vent, @@ -61,7 +27,7 @@ function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): numbe * point's (x, z) lies inside *every* segment's axis-aligned half- * extents, so a naive first-match returns the wrong slope (typically * segments[0]). We instead score each candidate by - * `|localY − analyticalSurfaceY(localX, localZ)|` and pick the + * `|localY − getRoofSegmentSurfaceY(localX, localZ)|` and pick the * smallest — the slope the user actually clicked is the one whose * sloped surface passes through the hit point. * @@ -101,7 +67,7 @@ export function resolveRoofSegmentHit( const halfW = seg.width / 2 + overhang const halfD = seg.depth / 2 + overhang if (Math.abs(local.x) <= halfW && Math.abs(local.z) <= halfD) { - const surfaceY = analyticalSurfaceY(seg, local.x, local.z) + const surfaceY = getRoofSegmentSurfaceY(seg, local.x, local.z) const score = Math.abs(local.y - surfaceY) if (!best || score < best.score) { best = { diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts index 7e539308..9a1368b3 100644 --- a/packages/nodes/src/shared/roof-surface.ts +++ b/packages/nodes/src/shared/roof-surface.ts @@ -1,5 +1,5 @@ import { - getActiveRoofHeight, + getRoofSegmentSurfaceY, getSegmentSlopeFrame, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, @@ -13,26 +13,7 @@ import * as THREE from 'three' // accessories don't reach across into a sibling kind for it. export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number { - const { roofType, wallHeight, depth, width } = seg - const rh = getActiveRoofHeight(seg) - const peakY = wallHeight + rh - if (rh === 0) return wallHeight - - if (roofType === 'gable') { - const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - t * rh - } - if (roofType === 'shed') { - const t = (lz + depth / 2) / (depth || 1) - return peakY - t * rh - } - if (roofType === 'hip') { - const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0 - const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - Math.max(fx, fz) * rh - } - const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - t * rh + return getRoofSegmentSurfaceY(seg, lx, lz) } // Outward normal for a roof surface tilting at angle θ in the horizontal