diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 6ee342bc..b3eb1a26 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -54,6 +54,7 @@ export { DEFAULT_GRID_STEP, type SnapServices, snapAngleToList, + snapPointAlongAngleRay, snapPointToAngle, snapPointToGrid, snapScalar, diff --git a/packages/core/src/services/snap.test.ts b/packages/core/src/services/snap.test.ts index 1a4ddb38..fa076fa3 100644 --- a/packages/core/src/services/snap.test.ts +++ b/packages/core/src/services/snap.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_ANGLE_STEP, DEFAULT_GRID_STEP, snapAngleToList, + snapPointAlongAngleRay, snapPointToAngle, snapPointToGrid, snapScalar, @@ -87,6 +88,54 @@ describe('snapPointToAngle', () => { }) }) +describe('snapPointAlongAngleRay', () => { + test('stays exactly on the 15° ray while distance-snapping to the grid step', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [2, 0.5] // ≈14° — snaps to 15° + const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.25) + expect(Math.atan2(snapped[1], snapped[0])).toBeCloseTo(Math.PI / 12, 10) + const distance = Math.hypot(snapped[0], snapped[1]) + expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10) + }) + + test('grid-snapping after the angle projection would pull the point off the ray', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [2, 0.5] + const offRay = snapPointToAngle(from, cursor, Math.PI / 12, 0.25) + expect(Math.atan2(offRay[1], offRay[0])).not.toBeCloseTo(Math.PI / 12, 4) + }) + + test('45° back-compat: locks to the diagonal with grid-multiple distance', () => { + const from: Vec2 = [1, 1] + const cursor: Vec2 = [2.1, 1.9] // near 45° from `from` + const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 4, 0.25) + expect(Math.atan2(snapped[1] - 1, snapped[0] - 1)).toBeCloseTo(Math.PI / 4, 10) + const distance = Math.hypot(snapped[0] - 1, snapped[1] - 1) + expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10) + }) + + test('preserves the projected distance when no distanceStep is given', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [1, 0.05] // near 0° + const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12) + expect(snapped[0]).toBeCloseTo(1, 10) // projection of (1, 0.05) onto 0° ray + expect(snapped[1]).toBeCloseTo(0, 10) + }) + + test('returns `from` for a zero-length segment', () => { + expect(snapPointAlongAngleRay([2, 3], [2, 3], Math.PI / 12, 0.25)).toEqual([2, 3]) + }) + + test('is idempotent on its own output', () => { + const from: Vec2 = [0.5, -1] + const cursor: Vec2 = [3.2, 0.4] + const once = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.5) + const twice = snapPointAlongAngleRay(from, once, Math.PI / 12, 0.5) + expect(twice[0]).toBeCloseTo(once[0], 10) + expect(twice[1]).toBeCloseTo(once[1], 10) + }) +}) + describe('snapAngleToList', () => { test('snaps to the nearest entry within tolerance', () => { const targets = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2] diff --git a/packages/core/src/services/snap.ts b/packages/core/src/services/snap.ts index faac9c74..3c10ba69 100644 --- a/packages/core/src/services/snap.ts +++ b/packages/core/src/services/snap.ts @@ -15,7 +15,7 @@ export type Vec3 = readonly [number, number, number] /** Default planar grid spacing in meters. Matches the editor's wall tool. */ export const DEFAULT_GRID_STEP = 0.25 -/** Default angle-snap step — π/12 = 15°. Wall tools also use π/4 (45°). */ +/** Default angle-snap step — π/12 = 15°. */ export const DEFAULT_ANGLE_STEP = Math.PI / 12 // ─── Grid snap ──────────────────────────────────────────────────────── @@ -111,6 +111,32 @@ export function snapPointToAngle( return gridStep == null ? projected : snapPointToGrid(projected, gridStep) } +/** + * Snaps a cursor point onto the nearest angle ray from `from` (multiples of + * `angleStep`), projecting the cursor onto that ray, then snaps the distance + * ALONG the ray to `distanceStep`. Unlike `snapPointToAngle` with a + * `gridStep`, the result stays exactly on the snapped ray — grid-snapping + * after the angle projection pulls points off non-axis rays. + */ +export function snapPointAlongAngleRay( + from: Vec2, + cursor: Vec2, + angleStep: number = DEFAULT_ANGLE_STEP, + distanceStep?: number, +): Vec2 { + const dx = cursor[0] - from[0] + const dz = cursor[1] - from[1] + if (dx === 0 && dz === 0) return [from[0], from[1]] + const angle = Math.atan2(dz, dx) + const snappedAngle = angleStep > 0 ? Math.round(angle / angleStep) * angleStep : angle + const dirX = Math.cos(snappedAngle) + const dirZ = Math.sin(snappedAngle) + const projected = dx * dirX + dz * dirZ + const distance = + distanceStep != null && distanceStep > 0 ? snapScalar(projected, distanceStep) : projected + return [from[0] + dirX * distance, from[1] + dirZ * distance] +} + /** * Snaps an angle (in radians) to the nearest entry in `snapAngles` (also in * radians). Returns the original angle if no entry is within `toleranceRad`. diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index ee482975..c87211c0 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -428,7 +428,8 @@ export function FloorplanRegistryMoveOverlay() { // 1) Grid snap baseline. Fresh catalog placement is absolute under // the cursor; existing moves preserve the cursor's grab offset. const gridStep = useEditor.getState().gridSnapStep - const snap = (value: number) => Math.round(value / gridStep) * gridStep + const snap = (value: number) => + event.shiftKey ? value : Math.round(value / gridStep) * gridStep const resolved = resolvePlanarCursorPosition({ cursor: [m[0], m[1]], original: [originalPosition[0], originalPosition[2]], @@ -442,11 +443,11 @@ export function FloorplanRegistryMoveOverlay() { // 2) Alignment snap layered on top. Treat the grid-snapped point // as the "proposed" position so alignment competes from a stable // base rather than the raw cursor jitter. Alt bypasses alignment - // entirely — same affordance Path 1 advertises in its "No Snap" + // entirely; Shift bypasses both grid and alignment // hint chip. let finalX = gridX let finalZ = gridZ - if (!event.altKey && candidateAnchors.length > 0) { + if (!(event.altKey || event.shiftKey) && candidateAnchors.length > 0) { // Translate the cached local bbox to the proposed pos to get the // moving anchors at that location. The entry's untransformed // bbox is in world meters relative to the node's origin, so a diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 1e878c9f..f9bf5cb0 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -8,6 +8,7 @@ import { type CeilingNode, type ColumnNode, calculateLevelMiters, + DEFAULT_ANGLE_STEP, type DoorNode, type ElevatorNode, emitter, @@ -38,6 +39,7 @@ import { StairSegmentNode as StairSegmentNodeSchema, sampleWallCenterline, sceneRegistry, + snapPointAlongAngleRay, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -47,7 +49,7 @@ import { ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, } from '@pascal-app/core' -import { useAlignmentGuides, useWallSnapIndicator } from '@pascal-app/editor' +import { useAlignmentGuides, useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Command, Ruler } from 'lucide-react' import { @@ -135,12 +137,10 @@ import { DEFAULT_STAIR_WIDTH, } from '../tools/stair/stair-defaults' import { - createWallOnCurrentLevel, isSegmentLongEnough, snapWallDraftPoint, snapWallDraftPointDetailed, snapPointToGrid as snapWallPointToGrid, - WALL_FINE_GRID_STEP, WALL_GRID_STEP, type WallPlanPoint, } from '../tools/wall/wall-drafting' @@ -220,8 +220,7 @@ const FLOORPLAN_GUIDE_SELECTION_STROKE_WIDTH = 0.05 const FLOORPLAN_GUIDE_HANDLE_HINT_OFFSET = 72 const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92 const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48 -const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45 -const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1 +const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 15 const FLOORPLAN_VIEW_ROTATION_DEG = 90 const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35 const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90 @@ -1277,7 +1276,7 @@ function buildGuideResizeDraft( function buildGuideRotationDraft( interaction: GuideInteractionState, pointerSvg: SvgPoint, - useFineIncrement: boolean, + bypassSnap: boolean, ): GuideTransformDraft { const pointerVector = subtractSvgPoints(pointerSvg, interaction.centerSvg) @@ -1292,12 +1291,9 @@ function buildGuideRotationDraft( const rawRotationSvg = Math.atan2(pointerVector[1], pointerVector[0]) - interaction.cornerBaseAngle - const snappedRotationSvg = snapAngleToIncrement( - rawRotationSvg, - useFineIncrement - ? FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES - : FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES, - ) + const snappedRotationSvg = bypassSnap + ? rawRotationSvg + : snapAngleToIncrement(rawRotationSvg, FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES) return { guideId: interaction.guideId, @@ -2174,49 +2170,29 @@ function isPointNearPlanPoint(a: WallPlanPoint, b: WallPlanPoint, threshold = 0. return Math.abs(a[0] - b[0]) < threshold && Math.abs(a[1] - b[1]) < threshold } -function calculatePolygonSnapPoint( - lastPoint: WallPlanPoint, - currentPoint: WallPlanPoint, -): WallPlanPoint { - const [x1, y1] = lastPoint - const [x, y] = currentPoint - const dx = x - x1 - const dy = y - y1 - const absDx = Math.abs(dx) - const absDy = Math.abs(dy) - const horizontalDist = absDy - const verticalDist = absDx - const diagonalDist = Math.abs(absDx - absDy) - const minDist = Math.min(horizontalDist, verticalDist, diagonalDist) - - if (minDist === diagonalDist) { - const diagonalLength = Math.min(absDx, absDy) - return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength] - } - - if (minDist === horizontalDist) { - return [x, y1] - } - - return [x1, y] -} - function snapPolygonDraftPoint({ point, start, angleSnap, + bypassSnap, }: { point: WallPlanPoint start?: WallPlanPoint angleSnap: boolean + bypassSnap?: boolean }): WallPlanPoint { - const snappedPoint: WallPlanPoint = [snapToHalf(point[0]), snapToHalf(point[1])] + if (bypassSnap) return point if (!(start && angleSnap)) { - return snappedPoint + return [snapToHalf(point[0]), snapToHalf(point[1])] } - return calculatePolygonSnapPoint(start, snappedPoint) + // 15° angle snap from the raw point, with the distance snapped along the + // ray to the grid step — grid-snapping the point itself would pull the + // vertex off non-axis rays (and matches the 3D slab / ceiling tools). + return [ + ...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, useEditor.getState().gridSnapStep), + ] } function pointMatchesWallPlanPoint( @@ -5508,16 +5484,18 @@ export function FloorplanPanel({ ) const floorplanOpeningLocalY = useMemo(() => { if (movingNode?.type === 'door' || movingNode?.type === 'window') { - return snapToHalf(movingNode.position[1]) + return shiftPressed ? movingNode.position[1] : snapToHalf(movingNode.position[1]) } if (isWindowBuildActive) { // Floorplan is top-down, so new windows need an explicit wall-local height. - return snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y) + return shiftPressed + ? FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y + : snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y) } return 0 - }, [isWindowBuildActive, movingNode]) + }, [isWindowBuildActive, movingNode, shiftPressed]) const isMarqueeSelectionToolActive = mode === 'select' && floorplanSelectionTool === 'marquee' && @@ -7566,9 +7544,10 @@ export function FloorplanPanel({ return } + const bypassSnap = shiftPressed || event.shiftKey const nextDraft = guideInteraction.mode === 'rotate' - ? buildGuideRotationDraft(guideInteraction, svgPoint, shiftPressed) + ? buildGuideRotationDraft(guideInteraction, svgPoint, bypassSnap) : guideInteraction.mode === 'translate' ? buildGuideTranslateDraft(guideInteraction, svgPoint) : buildGuideResizeDraft(guideInteraction, svgPoint) @@ -7625,15 +7604,14 @@ export function FloorplanPanel({ return } - // Wall endpoint move: grid snap only (no 45° angle snap from the - // fixed corner — that's draft-only behaviour). Shift switches - // to the fine grid step for precision. + // Wall endpoint move: grid snap only. Shift bypasses all snap. + const bypassSnap = shiftPressed || event.shiftKey const snapResult = snapWallDraftPointDetailed({ point: planPoint, walls, ignoreWallIds: [dragState.wallId], - step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, - magnetic: useEditor.getState().magneticSnap, + bypassSnap, + magnetic: !bypassSnap && useEditor.getState().magneticSnap, }) const snappedPoint = snapResult.point // Magnetic beacon at the endpoint when it locked onto existing geometry. @@ -7674,6 +7652,7 @@ export function FloorplanPanel({ ) if ( + !bypassSnap && !( previousDraft && pointsEqual(previousDraft.start, nextDraft.start) && @@ -7702,7 +7681,8 @@ export function FloorplanPanel({ } const chord = getWallChordFrame(wall) - const snappedPoint: WallPlanPoint = shiftPressed + const bypassSnap = shiftPressed || event.shiftKey + const snappedPoint: WallPlanPoint = bypassSnap ? planPoint : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] const rawCurveOffset = -( @@ -7711,7 +7691,7 @@ export function FloorplanPanel({ ) const nextCurveOffset = normalizeWallCurveOffset( wall, - shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset), + bypassSnap ? rawCurveOffset : snapToHalf(rawCurveOffset), ) if (curveDragState.currentCurveOffset === nextCurveOffset) { @@ -7721,7 +7701,9 @@ export function FloorplanPanel({ curveDragState.currentCurveOffset = nextCurveOffset setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset }) setCursorPoint(snappedPoint) - sfxEmitter.emit('sfx:grid-snap') + if (!bypassSnap) { + sfxEmitter.emit('sfx:grid-snap') + } } const commitGuideInteraction = (event: PointerEvent) => { @@ -7739,9 +7721,10 @@ export function FloorplanPanel({ } const svgPoint = getSvgPointFromClientPoint(event.clientX, event.clientY) + const bypassSnap = shiftPressed || event.shiftKey const nextDraft = svgPoint ? interaction.mode === 'rotate' - ? buildGuideRotationDraft(interaction, svgPoint, shiftPressed) + ? buildGuideRotationDraft(interaction, svgPoint, bypassSnap) : interaction.mode === 'translate' ? buildGuideTranslateDraft(interaction, svgPoint) : buildGuideResizeDraft(interaction, svgPoint) @@ -7949,7 +7932,10 @@ export function FloorplanPanel({ return } - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const bypassSnap = shiftPressed || event.shiftKey + const snappedPoint: WallPlanPoint = bypassSnap + ? planPoint + : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] setCursorPoint(snappedPoint) const currentDraft = siteBoundaryDraftRef.current @@ -7962,7 +7948,9 @@ export function FloorplanPanel({ return } - sfxEmitter.emit('sfx:grid-snap') + if (!bypassSnap) { + sfxEmitter.emit('sfx:grid-snap') + } const nextPolygon = [...currentDraft.polygon] nextPolygon[dragState.vertexIndex] = snappedPoint @@ -8037,6 +8025,7 @@ export function FloorplanPanel({ exitSiteEditingToSelect, getPlanPointFromClientPoint, setSiteBoundaryLivePreview, + shiftPressed, site, siteBoundaryWorldPolygon, siteVertexDragState, @@ -8176,25 +8165,26 @@ export function FloorplanPanel({ stopPropagation: () => {}, } as any) }, []) + // Emits `planPoint` unchanged — callers own snapping. Re-quantizing here + // (the old behaviour) destroyed magnetic corner / midpoint snaps the draft + // branches had already resolved, desyncing the 2D pipeline from the 3D + // tools that subscribe to these grid events. const emitFloorplanGridEvent = useCallback( ( eventType: 'move' | 'click' | 'double-click', planPoint: WallPlanPoint, nativeEvent: ReactMouseEvent | ReactPointerEvent, ) => { - const snappedPoint = getSnappedFloorplanPoint(planPoint) const cos = Math.cos(buildingRotationY) const sin = Math.sin(buildingRotationY) - const worldX = buildingPosition[0] + snappedPoint[0] * cos + snappedPoint[1] * sin - const worldZ = buildingPosition[2] - snappedPoint[0] * sin + snappedPoint[1] * cos + const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin + const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos emitter.emit(`grid:${eventType}` as any, { nativeEvent: nativeEvent.nativeEvent as any, position: [worldX, floorplanGridWorldY, worldZ], - localPosition: [snappedPoint[0], floorplanGridLocalY, snappedPoint[1]], + localPosition: [planPoint[0], floorplanGridLocalY, planPoint[1]], }) - - return snappedPoint }, [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY], ) @@ -8413,7 +8403,7 @@ export function FloorplanPanel({ } if (referenceScaleDraft) { - emitFloorplanGridEvent('move', planPoint, event) + emitFloorplanGridEvent('move', getSnappedFloorplanPoint(planPoint), event) setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint, @@ -8430,21 +8420,24 @@ export function FloorplanPanel({ } if (isCeilingBuildActive) { - // Polygon vertex: grid (snapToHalf) + optional 45° angle snap from - // the previous vertex. Wall magnetic snap may still win, while + const bypassSnap = shiftPressed || event.shiftKey + // Polygon vertex: grid (snapToHalf) or 15° angle snap from the + // previous vertex. Wall magnetic snap may still win, while // generic alignment runs only when angle snap is OFF (first vertex, // or Shift held) so it does not pull a locked angle sideways. - const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed + const angleSnap = ceilingDraftPoints.length > 0 && !bypassSnap const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: ceilingDraftPoints[ceilingDraftPoints.length - 1], angleSnap, + bypassSnap, }) const snappedPoint = resolveCeilingPlanPointSnap({ rawPoint: planPoint, fallbackPoint, levelId, altKey: event.altKey, + shiftKey: bypassSnap, align: !angleSnap, }).point @@ -8456,8 +8449,11 @@ export function FloorplanPanel({ } if (isRoofBuildActive) { - let snappedPoint = getSnappedFloorplanPoint(planPoint) - snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) + const bypassSnap = shiftPressed || event.shiftKey + let snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint) + snappedPoint = alignFloorplanDraftPoint(snappedPoint, { + bypass: event.altKey || bypassSnap, + }) emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, @@ -8474,23 +8470,31 @@ export function FloorplanPanel({ } if (isFenceBuildActive) { + const bypassSnap = shiftPressed || event.shiftKey // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then // Figma alignment — same endpoint-wins precedence as the wall branch. + // While a draft is open the segment locks to 15° rays from its start + // unless Shift is held; Shift bypasses grid, magnetic, angle, and + // alignment snap. + const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap const fenceSnapped = snapFenceDraftPoint({ point: planPoint, walls, fences, - step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, + start: fenceDraftStart ?? undefined, + angleSnap: fenceAngleSnap, + bypassSnap, }) - const fenceGridBase = snapWallPointToGrid( - planPoint, - shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP, - ) + const fenceGridBase = bypassSnap ? planPoint : snapWallPointToGrid(planPoint) const fenceLocked = - fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] + !bypassSnap && + (fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]) let snappedPoint = fenceSnapped - if (fenceLocked) useAlignmentGuides.getState().clear() - else snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey }) + if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear() + else + snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { + bypass: event.altKey || bypassSnap, + }) emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => @@ -8511,11 +8515,13 @@ export function FloorplanPanel({ // the local polygon-draft state actually updates as the cursor // moves (the catch-all would otherwise swallow the move event). if (isPolygonBuildActive) { - const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed + const bypassSnap = shiftPressed || event.shiftKey + const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], angleSnap, + bypassSnap, }) let snappedPoint = fallbackPoint if (isSlabBuildActive) { @@ -8524,12 +8530,15 @@ export function FloorplanPanel({ fallbackPoint, levelId, altKey: event.altKey, + shiftKey: bypassSnap, align: !angleSnap, }).point } else if (angleSnap) { useAlignmentGuides.getState().clear() } else { - snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey }) + snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { + bypass: event.altKey || bypassSnap, + }) } // Emit `grid:move` so the registry-driven slab tool also tracks @@ -8538,7 +8547,7 @@ export function FloorplanPanel({ setCursorPoint((previousPoint) => { const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint)) - if (hasChanged && activePolygonDraftPoints.length > 0) { + if (!bypassSnap && hasChanged && activePolygonDraftPoints.length > 0) { sfxEmitter.emit('sfx:grid-snap') } return snappedPoint @@ -8594,7 +8603,8 @@ export function FloorplanPanel({ // routing through `grid:move`, which would otherwise be processed // by the floor strategy and drop the item to floor height. if (isCeilingItemPlacementActive) { - const snappedPoint = getSnappedFloorplanPoint(planPoint) + const bypassSnap = shiftPressed || event.shiftKey + const snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint) setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, ) @@ -8608,7 +8618,8 @@ export function FloorplanPanel({ // comment there). Wall build skips this so its own branch below // updates local `draftEnd` state alongside the registry tool. if (!isWallBuildActive && isFloorplanGridInteractionActive) { - const snappedPoint = emitFloorplanGridEvent('move', planPoint, event) + const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) + emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, ) @@ -8630,27 +8641,31 @@ export function FloorplanPanel({ return } - // Wall draft: grid snap (orthogonal walls follow naturally from a - // grid-aligned start; Shift = fine 0.05m step), then Figma-style - // alignment layered on top. An existing wall endpoint / join snap - // wins outright — never pull the cursor off a corner the user is - // closing onto — so alignment runs ONLY when the wall snap left the - // point on the plain grid. Alt bypasses alignment. + // Wall draft: grid + magnetic snap, then Figma-style alignment. + // While a draft is open the segment locks to 15° rays from its + // start unless Shift is held. Shift bypasses grid, magnetic, angle, + // and alignment snap. + const bypassSnap = shiftPressed || event.shiftKey + const wallAngleSnap = draftStart !== null && !bypassSnap const wallSnap = snapWallDraftPointDetailed({ point: planPoint, walls, - step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, - magnetic: useEditor.getState().magneticSnap, + start: draftStart ?? undefined, + angleSnap: wallAngleSnap, + bypassSnap, + magnetic: !bypassSnap && useEditor.getState().magneticSnap, }) const wallSnapped = wallSnap.point // Locked onto existing geometry (corner / midpoint / crossing / edge) → // that snap wins, so skip Figma alignment and stand the beacon there. const lockedToWall = wallSnap.snap !== null let snappedPoint = wallSnapped - if (lockedToWall) { + if (lockedToWall || wallAngleSnap) { useAlignmentGuides.getState().clear() } else { - snappedPoint = alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey }) + snappedPoint = alignFloorplanDraftPoint(wallSnapped, { + bypass: event.altKey || bypassSnap, + }) } useWallSnapIndicator .getState() @@ -8668,9 +8683,8 @@ export function FloorplanPanel({ setDraftEnd((previousEnd) => { if ( - !previousEnd || - previousEnd[0] !== snappedPoint[0] || - previousEnd[1] !== snappedPoint[1] + !bypassSnap && + (!previousEnd || previousEnd[0] !== snappedPoint[0] || previousEnd[1] !== snappedPoint[1]) ) { sfxEmitter.emit('sfx:grid-snap') } @@ -8874,17 +8888,10 @@ export function FloorplanPanel({ // call. `emitFloorplanGridEvent('click', …)` in // `useFloorplanBackgroundPlacement` fires it synchronously // just before this callback runs, so by the time we get here - // the wall already exists in the scene. - // - // We still attempt the create as a fallback in case the 3D - // tool isn't mounted (unusual — both views are always - // mounted today, but defensive). When the wall already - // exists `createWallOnCurrentLevel` returns null via its - // duplicate-detection branch; we treat that as "the 3D side - // committed" and chain the draft state forward instead of - // clearing it (the previous behaviour caused the 2nd-segment - // draft to silently break after click 2). - const createdWall = createWallOnCurrentLevel(draftStart, point) + // the wall already exists in the scene. Committing here as + // well used to double-create walls whenever the two snap + // pipelines resolved endpoints ≥1e-6 apart (the duplicate + // check compares exact endpoints). // Alt commits a single wall: drop the draft so the next click // starts a fresh segment instead of chaining off this endpoint. @@ -8895,9 +8902,10 @@ export function FloorplanPanel({ return } - const nextStart: WallPlanPoint = createdWall - ? [createdWall.end[0], createdWall.end[1]] - : point + // Chain the next segment from the 3D tool's resolved commit + // point (it may have corner-snapped or split-adjusted the + // endpoint) so both views draft from the same start. + const nextStart: WallPlanPoint = useSegmentDraftChain.getState().wall ?? point setDraftStart(nextStart) setDraftEnd(nextStart) setCursorPoint(nextStart) @@ -8930,6 +8938,7 @@ export function FloorplanPanel({ walls: WallNode[] start?: WallPlanPoint angleSnap?: boolean + bypassSnap?: boolean step?: number }) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }), [], @@ -8939,6 +8948,7 @@ export function FloorplanPanel({ ceilingDraftPoints, clearFencePlacementDraft, clearRoofPlacementDraft, + clearWallPlacementDraft, emitFloorplanGridEvent, fenceDraftStart, fences, @@ -8994,7 +9004,7 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() - emitFloorplanGridEvent('click', planPoint, event) + emitFloorplanGridEvent('click', getSnappedFloorplanPoint(planPoint), event) if (!referenceScaleDraft.start) { setReferenceScaleDraft({ @@ -9137,11 +9147,13 @@ export function FloorplanPanel({ return } - const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed + const bypassSnap = shiftPressed || event.shiftKey + const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], angleSnap, + bypassSnap, }) if (isCeilingBuildActive) { @@ -9150,6 +9162,7 @@ export function FloorplanPanel({ fallbackPoint, levelId, altKey: event.altKey, + shiftKey: bypassSnap, align: !angleSnap, }).point emitFloorplanGridEvent('double-click', snappedPoint, event) @@ -9165,6 +9178,7 @@ export function FloorplanPanel({ fallbackPoint, levelId, altKey: event.altKey, + shiftKey: bypassSnap, align: !angleSnap, }).point // Slab is registry-driven: forward the double-click so the 3D tool diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 3d7c434e..9eef3772 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -3,6 +3,7 @@ import { type AnyNode, type AnyNodeId, + DEFAULT_ANGLE_STEP, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -40,8 +41,6 @@ import { useInvisibleHitAreaMaterial, } from './node-arrow-handles' -const ROTATE_SNAP = Math.PI / 12 // 15° - /** * Group-rotate gizmo. When 2+ transformable nodes in the active level frame are * selected, a single rotation handle appears at the selection's bounding-box @@ -201,7 +200,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { let delta = angleOf(moveHit) - initialAngle while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI - if (e.shiftKey) delta = Math.round(delta / ROTATE_SNAP) * ROTATE_SNAP + if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP // Orbit each node's anchor point(s) CCW by `delta` (atan2 x→z sense) and // turn its yaw by `-delta` to match three.js Y-rotation handedness (same 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 758bee54..e1b0ae86 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -11,7 +11,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' -import { type Camera, type Object3D, type Plane, Vector2, type Vector3 } from 'three' +import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' @@ -27,9 +27,12 @@ type IntersectPlane = ( target: Vector3, ) => Vector3 | null +type GetPointerRay = (clientX: number, clientY: number, target: Ray) => Ray + export type HandleDragStartContext = { event: ThreeEvent camera: Camera + getPointerRay: GetPointerRay intersectPlane: IntersectPlane initialNode: AnyNode node: AnyNode @@ -40,6 +43,7 @@ export type HandleDragStartContext = { export type HandleDragMoveContext = { event: PointerEvent + getPointerRay: GetPointerRay intersectPlane: IntersectPlane } @@ -121,13 +125,20 @@ export function useHandleDrag(args: UseHandleDragArgs) { rideObject.updateMatrixWorld() const ndc = new Vector2() - const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => { + const setPointerRay = (clientX: number, clientY: number) => { const rect = gl.domElement.getBoundingClientRect() ndc.set( ((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1, ) raycaster.setFromCamera(ndc, camera) + } + const getPointerRay: GetPointerRay = (clientX, clientY, target) => { + setPointerRay(clientX, clientY) + return target.copy(raycaster.ray) + } + const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => { + setPointerRay(clientX, clientY) return raycaster.ray.intersectPlane(plane, target) } @@ -137,6 +148,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { const session = args.onStart({ event, camera, + getPointerRay, intersectPlane, initialNode, node, @@ -159,7 +171,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { let lastPatch: Partial | null = null const onMove = (moveEvent: PointerEvent) => { - const patch = session.move({ event: moveEvent, intersectPlane }) + const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane }) if (!patch) return lastPatch = patch useLiveNodeOverrides.getState().set(overrideId, patch as Record) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index a8538131..0ef913c5 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -6,6 +6,7 @@ import { type ArcResizeHandle, type Cursor, createSceneApi, + DEFAULT_ANGLE_STEP, type HandleDescriptor, type HandlePortal, type LinearResizeHandle, @@ -34,6 +35,7 @@ import { OrthographicCamera, Plane, Quaternion, + Ray, RingGeometry, Vector3, } from 'three' @@ -59,6 +61,43 @@ import { type HandleDragControls, useHandleDrag } from './handles/use-handle-dra // Pooled scratch for the handle rig's world-relative pose mapping. const _rigRelative = new Matrix4() const _rigScratchScale = new Vector3() +const _resizeAxisW = new Vector3() +const _resizeScale = new Vector3() +const _resizeQuaternion = new Quaternion() +const _resizeOriginW = new Vector3() +const _resizePositionW = new Vector3() +const _resizeRay = new Ray() +const _resizeRayW = new Vector3() + +function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) { + target.set(0, 0, 0) + if (axis === 'x') target.x = 1 + else if (axis === 'y') target.y = 1 + else target.z = 1 + return target +} + +function axisScale(axis: 'x' | 'y' | 'z', scale: Vector3) { + return axis === 'x' ? scale.x : axis === 'y' ? scale.y : scale.z +} + +function closestAxisParameterToRay(axisOrigin: Vector3, axisDirection: Vector3, ray: Ray) { + _resizeRayW.subVectors(axisOrigin, ray.origin) + const b = axisDirection.dot(ray.direction) + const d = axisDirection.dot(_resizeRayW) + const e = ray.direction.dot(_resizeRayW) + const denominator = 1 - b * b + if (Math.abs(denominator) < 1e-6) { + return -d + } + + const axisParameter = (b * e - d) / denominator + const rayParameter = e + b * axisParameter + if (rayParameter < 0) { + return -d + } + return axisParameter +} export { ARROW_COLOR, @@ -579,34 +618,31 @@ function LinearArrow({ rideObject, setIsDragging, onStart: ({ - camera: dragCamera, event, + getPointerRay, initialNode, - intersectPlane, nodeId, rideObject: dragRideObject, sceneApi, }) => { - const initialFrameInverse = new Matrix4().copy(dragRideObject.matrixWorld).invert() - const worldOrigin = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld) - const planeNormal = new Vector3().subVectors(dragCamera.position, worldOrigin).setY(0) - if (planeNormal.lengthSq() === 0) return null - planeNormal.normalize() - const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin) + dragRideObject.matrixWorld.decompose(_resizePositionW, _resizeQuaternion, _resizeScale) + _resizeOriginW.set(...position).applyMatrix4(dragRideObject.matrixWorld) + axisVector(descriptor.axis, _resizeAxisW).applyQuaternion(_resizeQuaternion).normalize() + const localToWorldScale = axisScale(descriptor.axis, _resizeScale) + if (Math.abs(localToWorldScale) < 1e-6 || _resizeAxisW.lengthSq() === 0) return null - const hitWorld = new Vector3() - if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) { - return null - } - const hitLocal = hitWorld.clone().applyMatrix4(initialFrameInverse) + const initialPointer = + closestAxisParameterToRay( + _resizeOriginW, + _resizeAxisW, + getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay), + ) / localToWorldScale const overrideId = (descriptor.kind === 'linear-resize' ? descriptor.overrideTarget?.(initialNode as never, sceneApi) : undefined) ?? nodeId const initialValue = descriptor.currentValue(initialNode) - const initialPointer = - descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi) const gridSnapStep = @@ -634,22 +670,19 @@ function LinearArrow({ useEditor.getState().setActiveHandleDrag(null) } }, - move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { - const intersection = new Vector3() - if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, intersection)) { - return null - } - const intersectionLocal = intersection.clone().applyMatrix4(initialFrameInverse) + move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { const currentPointer = - descriptor.axis === 'x' - ? intersectionLocal.x - : descriptor.axis === 'y' - ? intersectionLocal.y - : intersectionLocal.z + closestAxisParameterToRay( + _resizeOriginW, + _resizeAxisW, + getMovePointerRay(moveEvent.clientX, moveEvent.clientY, _resizeRay), + ) / localToWorldScale const delta = currentPointer - initialPointer const rawNext = initialValue + delta * factor const snappedNext = - gridSnapStep && gridSnapStep > 0 ? snapScalar(rawNext, gridSnapStep) : rawNext + !moveEvent.shiftKey && gridSnapStep && gridSnapStep > 0 + ? snapScalar(rawNext, gridSnapStep) + : rawNext const next = Math.min(maxBound, Math.max(minBound, snappedNext)) return descriptor.apply(initialNode as never, next, sceneApi) as Partial }, @@ -1096,9 +1129,8 @@ function ArcArrow({ while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI - if (moveEvent.shiftKey && descriptor.shape === 'rotate') { - const step = Math.PI / 12 - delta = Math.round(delta / step) * step + if (!moveEvent.shiftKey && descriptor.shape === 'rotate') { + delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP } if (isRotateShape && !isNodeNormalRot) { diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index e81cde62..72929e3f 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -202,12 +202,14 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro restoreLevels = snapLevelsToTruePositions() } - // Hide scan and guide nodes directly so they are excluded from the - // thumbnail regardless of whether ScanSystem/GuideSystem listeners are - // registered. Returns a function that restores the original visibility. + // Hide scan, guide, and spawn nodes directly so they are excluded from + // the thumbnail regardless of whether ScanSystem/GuideSystem listeners + // are registered. Spawn renders on SCENE_LAYER for occlusion, so the + // thumbnail camera's layer mask can't filter it either. Returns a + // function that restores the original visibility. const restoreNodeVisibility = (() => { const saved = new Map() - for (const type of ['scan', 'guide'] as const) { + for (const type of ['scan', 'guide', 'spawn'] as const) { const ids = sceneRegistry.byType[type]! ids.forEach((id) => { const node = sceneRegistry.nodes.get(id) @@ -238,18 +240,21 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // Notify other systems (wall cutouts, selection manager) to restore // their overrides before capture and re-apply them after. - emitter.emit('thumbnail:before-capture', undefined) - ;(renderer as any).setClearAlpha(0) - renderer.setRenderTarget(rt) - pipelineRef.current.render() - renderer.setRenderTarget(null) - emitter.emit('thumbnail:after-capture', undefined) - - // Restore level positions, levelMode, and node visibility immediately after the - // render — before the async GPU readback. - restoreLevels() - restoreLevelMode?.() - restoreNodeVisibility() + try { + emitter.emit('thumbnail:before-capture', undefined) + ;(renderer as any).setClearAlpha(0) + renderer.setRenderTarget(rt) + pipelineRef.current.render() + } finally { + // Restore level positions, levelMode, and node visibility immediately + // after the render — before the async GPU readback. Runs in `finally` + // so a render failure can't leave helpers permanently hidden. + renderer.setRenderTarget(null) + emitter.emit('thumbnail:after-capture', undefined) + restoreLevels() + restoreLevelMode?.() + restoreNodeVisibility() + } // Read pixels from the RT asynchronously. // WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must @@ -364,12 +369,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro cameraData.resolution = { w: outW, h: outH } } else { // Fallback: plain render directly to the canvas - emitter.emit('thumbnail:before-capture', undefined) - gl.render(scene, thumbnailCamera) - emitter.emit('thumbnail:after-capture', undefined) - restoreLevels() - restoreLevelMode?.() - restoreNodeVisibility() + try { + emitter.emit('thumbnail:before-capture', undefined) + gl.render(scene, thumbnailCamera) + } finally { + emitter.emit('thumbnail:after-capture', undefined) + restoreLevels() + restoreLevelMode?.() + restoreNodeVisibility() + } let outW: number let outH: number diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 4293df6c..387558c1 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -5,23 +5,21 @@ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react' import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' +import useSegmentDraftChain from '../../store/use-segment-draft-chain' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' -import { - WALL_FINE_GRID_STEP, - WALL_GRID_STEP, - type WallPlanPoint, -} from '../tools/wall/wall-drafting' +import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting' type UseFloorplanBackgroundPlacementArgs = { activePolygonDraftPoints: WallPlanPoint[] ceilingDraftPoints: WallPlanPoint[] clearFencePlacementDraft: () => void clearRoofPlacementDraft: () => void + clearWallPlacementDraft: () => void emitFloorplanGridEvent: ( type: 'click' | 'double-click' | 'move', planPoint: WallPlanPoint, event: ReactMouseEvent, - ) => WallPlanPoint + ) => void fenceDraftStart: WallPlanPoint | null fences: FenceNode[] findClosestWallPoint: ( @@ -67,6 +65,7 @@ type UseFloorplanBackgroundPlacementArgs = { walls: WallNode[] start?: WallPlanPoint angleSnap?: boolean + bypassSnap?: boolean step?: number gridSnap?: (point: WallPlanPoint) => WallPlanPoint }) => WallPlanPoint @@ -74,6 +73,7 @@ type UseFloorplanBackgroundPlacementArgs = { point: WallPlanPoint start?: WallPlanPoint angleSnap: boolean + bypassSnap?: boolean }) => WallPlanPoint toPoint2D: (point: WallPlanPoint) => { x: number; y: number } walls: WallNode[] @@ -81,7 +81,7 @@ type UseFloorplanBackgroundPlacementArgs = { * Snap a building-local plan point to the world XZ grid at `step`. * Injected so the hook doesn't have to know the building's rotation * or position — used by wall / fence branches that snap at variable - * step (Shift = fine). + * step. */ worldGridSnap: (point: WallPlanPoint, step: number) => WallPlanPoint } @@ -91,6 +91,7 @@ export function useFloorplanBackgroundPlacement({ ceilingDraftPoints, clearFencePlacementDraft, clearRoofPlacementDraft, + clearWallPlacementDraft, emitFloorplanGridEvent, fenceDraftStart, fences, @@ -154,21 +155,24 @@ export function useFloorplanBackgroundPlacement({ } if (isCeilingBuildActive) { + const bypassSnap = shiftPressed || event.shiftKey // Align the committed vertex the same way the move-preview did, so // the placed point matches what the user saw. Wall magnetic snap may // still win; generic alignment is skipped when angle snap owns the // vertex (matches the move branch). - const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed + const angleSnap = ceilingDraftPoints.length > 0 && !bypassSnap const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: ceilingDraftPoints[ceilingDraftPoints.length - 1], angleSnap, + bypassSnap, }) const snappedPoint = resolveCeilingPlanPointSnap({ rawPoint: planPoint, fallbackPoint, levelId, altKey: event.altKey, + shiftKey: bypassSnap, align: !angleSnap, }).point @@ -178,9 +182,11 @@ export function useFloorplanBackgroundPlacement({ } if (isRoofBuildActive) { - const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { - bypass: event.altKey, - }) + const bypassSnap = shiftPressed || event.shiftKey + const snappedPoint = alignFloorplanDraftPoint( + bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint), + { bypass: event.altKey || bypassSnap }, + ) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) @@ -194,35 +200,57 @@ export function useFloorplanBackgroundPlacement({ } if (isFenceBuildActive) { + const bypassSnap = shiftPressed || event.shiftKey // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then // Figma alignment — endpoint snap wins (same precedence as move). - // `gridSnap` keeps the snap on the world XZ grid even when the - // building is rotated. - const fenceStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP + // While a draft is open the segment locks to 15° rays from its + // start unless Shift is held; Shift bypasses grid, magnetic, + // angle, and alignment snap. `gridSnap` keeps the regular snap + // on the world XZ grid even when the building is rotated. + const fenceStep = WALL_GRID_STEP + const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap const fenceSnapped = snapFenceDraftPoint({ point: planPoint, walls, fences, - step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, + start: fenceDraftStart ?? undefined, + angleSnap: fenceAngleSnap, + bypassSnap, gridSnap: (p) => worldGridSnap(p, fenceStep), }) - const fenceGridBase = worldGridSnap(planPoint, fenceStep) + const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep) const fenceLocked = - fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] - const snappedPoint = fenceLocked - ? fenceSnapped - : alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey }) + !bypassSnap && + (fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]) + const snappedPoint = + fenceLocked || fenceAngleSnap + ? fenceSnapped + : alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap }) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) + // Double-click finishes the chain. The emit above already made the + // 3D fence tool stopDrafting (its detail >= 2 guard), so close the + // 2D draft too — leaving it open desyncs the two views. + if (fenceDraftStart && event.detail >= 2) { + clearFencePlacementDraft() + return true + } + if (!fenceDraftStart) { setFenceDraftStart(snappedPoint) setFenceDraftEnd(snappedPoint) } else if ( getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01 ) { - clearFencePlacementDraft() + // The 3D fence tool owns creation and keeps chaining from the + // committed fence's resolved end — chain the 2D draft from the + // same published point so both views draft the next segment + // from the same start. + const nextStart = useSegmentDraftChain.getState().fence ?? snappedPoint + setFenceDraftStart(nextStart) + setFenceDraftEnd(nextStart) } else { setFenceDraftEnd(snappedPoint) } @@ -235,11 +263,13 @@ export function useFloorplanBackgroundPlacement({ // swallow the click and skip local draft state updates — leaving // the 2D draft polygon invisible while the 3D tool builds fine). if (isPolygonBuildActive) { - const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed + const bypassSnap = shiftPressed || event.shiftKey + const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], angleSnap, + bypassSnap, }) let snappedPoint = fallbackPoint if (isSlabBuildActive) { @@ -248,10 +278,13 @@ export function useFloorplanBackgroundPlacement({ fallbackPoint, levelId, altKey: event.altKey, + shiftKey: bypassSnap, align: !angleSnap, }).point } else if (!angleSnap) { - snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey }) + snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { + bypass: event.altKey || bypassSnap, + }) } // Emit the grid event so the registry-driven slab tool also @@ -275,25 +308,44 @@ export function useFloorplanBackgroundPlacement({ // / draftEnd state in the floor plan would never update, leaving // the dashed-line draft preview invisible. if (isWallBuildActive) { + const bypassSnap = shiftPressed || event.shiftKey // Wall draft: grid snap (+ existing-wall endpoint/join snap), then // Figma alignment — endpoint/join snap wins (same precedence as the // move-preview branch), so committing onto a corner still works. - // `gridSnap` keeps the snap on the world XZ grid even when the - // building is rotated. - const wallStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP + // While a draft is open the segment locks to 15° rays from its + // start unless Shift is held; Shift bypasses grid, magnetic, + // angle, and alignment snap. `gridSnap` keeps the regular snap + // on the world XZ grid even when the building is rotated. + const wallStep = WALL_GRID_STEP + const wallAngleSnap = draftStart !== null && !bypassSnap const wallSnapped = snapWallDraftPoint({ point: planPoint, walls, - step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, + start: draftStart ?? undefined, + angleSnap: wallAngleSnap, + bypassSnap, gridSnap: (p) => worldGridSnap(p, wallStep), }) - const wallGridBase = worldGridSnap(planPoint, wallStep) - const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1] - const snappedPoint = wallLocked - ? wallSnapped - : alignFloorplanDraftPoint(wallSnapped, { bypass: false }) + const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep) + const wallLocked = + !bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]) + const snappedPoint = + wallLocked || wallAngleSnap + ? wallSnapped + : alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey || bypassSnap }) emitFloorplanGridEvent('click', snappedPoint, event) + + // Double-click finishes the chain. The emit above already made the + // 3D wall tool stopDrafting (its detail >= 2 guard), so close the + // 2D draft too — otherwise it stays open against a closed 3D tool + // and the next previewed segment is silently never created. + if (draftStart && event.detail >= 2) { + clearWallPlacementDraft() + setCursorPoint(snappedPoint) + return true + } + handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey }) return true } @@ -311,7 +363,8 @@ export function useFloorplanBackgroundPlacement({ // local floor-plan draft handler (column / spawn / shelf / etc.). // The tool's `grid:click` subscriber owns the placement. if (isFloorplanGridInteractionActive) { - const snappedPoint = emitFloorplanGridEvent('click', planPoint, event) + const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) + emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) return true } @@ -323,6 +376,7 @@ export function useFloorplanBackgroundPlacement({ ceilingDraftPoints, clearFencePlacementDraft, clearRoofPlacementDraft, + clearWallPlacementDraft, emitFloorplanGridEvent, fenceDraftStart, fences, diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index c45fb78d..ac7353ee 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -11,7 +11,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' +import { BoxGeometry, type Group, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' import { clearCeilingSnapFeedback, @@ -153,6 +153,7 @@ const CeilingSelectionAffordance = ({ const [draggedCornerIndex, setDraggedCornerIndex] = useState(null) const [previewPolygon, setPreviewPolygon] = useState | null>(null) const dragRef = useRef(null) + const bracketsRootRef = useRef(null) const raycasterRef = useRef(new Raycaster()) const ndcRef = useRef(new Vector2()) const planeRef = useRef(new Plane()) @@ -293,19 +294,23 @@ const CeilingSelectionAffordance = ({ initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]), initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]), ] - const gridNextPosition: [number, number] = [ - initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]), - initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]), - ] + const gridNextPosition: [number, number] = event.shiftKey + ? rawNextPosition + : [ + initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]), + initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]), + ] const nextPosition = resolveCeilingPlanPointSnap({ rawPoint: rawNextPosition, fallbackPoint: gridNextPosition, levelId, excludeId: drag.ceilingId, altKey: event.altKey, + shiftKey: event.shiftKey, }).point if ( + !event.shiftKey && drag.previousSnappedPosition && (nextPosition[0] !== drag.previousSnappedPosition[0] || nextPosition[1] !== drag.previousSnappedPosition[1]) @@ -372,6 +377,25 @@ const CeilingSelectionAffordance = ({ } }, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit]) + // The brackets render on SCENE_LAYER (scene-depth occlusion), so unlike + // EDITOR_LAYER affordances the thumbnail camera can't filter them — hide + // them around captures via synchronous Object3D.visible mutation (the + // capture renders right after the emit), same as `site-boundary-editor.tsx`. + useEffect(() => { + const hideForCapture = () => { + if (bracketsRootRef.current) bracketsRootRef.current.visible = false + } + const restoreAfterCapture = () => { + if (bracketsRootRef.current) bracketsRootRef.current.visible = true + } + emitter.on('thumbnail:before-capture', hideForCapture) + emitter.on('thumbnail:after-capture', restoreAfterCapture) + return () => { + emitter.off('thumbnail:before-capture', hideForCapture) + emitter.off('thumbnail:after-capture', restoreAfterCapture) + } + }, []) + useEffect(() => { let frameId = 0 @@ -401,7 +425,10 @@ const CeilingSelectionAffordance = ({ if (!levelObject || corners.length === 0) return null return createPortal( - + {corners.map((corner, index) => ( = ({ buildingId, levelId, } const onGridMove = (event: GridEvent) => { + const bypassSnap = event.nativeEvent?.shiftKey === true const [gridX, gridZ] = alignPoint( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, + bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, + bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, event.localPosition[0], event.localPosition[2], - event.nativeEvent?.altKey === true, + event.nativeEvent?.altKey === true || bypassSnap, ) const supportY = resolveElevatorSupportY({ buildingId: currentBuildingId, @@ -220,6 +221,7 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, }) if ( + !bypassSnap && previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) ) { @@ -237,12 +239,13 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, }) if (!latestBuildingId) return + const bypassSnap = event.nativeEvent?.shiftKey === true const [gridX, gridZ] = alignPoint( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, + bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, + bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, event.localPosition[0], event.localPosition[2], - event.nativeEvent?.altKey === true, + event.nativeEvent?.altKey === true || bypassSnap, ) commitElevatorPlacement( latestBuildingId, diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx index f98004fa..10a2b201 100644 --- a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -131,8 +131,9 @@ export function MoveElevatorTool({ } const onGridMove = (event: GridEvent) => { - const rawX = Math.round(event.localPosition[0] * 2) / 2 - const rawZ = Math.round(event.localPosition[2] * 2) / 2 + const bypassSnap = event.nativeEvent?.shiftKey === true + const rawX = bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2 + const rawZ = bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2 const anchor = dragAnchorRef.current ?? [rawX, rawZ] dragAnchorRef.current = anchor const gridX = movingNode.position[0] + (rawX - anchor[0]) @@ -145,6 +146,7 @@ export function MoveElevatorTool({ }) if ( + !bypassSnap && previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) ) { diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index 5ea60acb..f7dc1f92 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -1,8 +1,10 @@ import { + DEFAULT_ANGLE_STEP, FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, + snapPointAlongAngleRay, useScene, type WallNode, } from '@pascal-app/core' @@ -12,9 +14,7 @@ import useEditor from '../../../store/use-editor' import { findWallSnapTarget, getSegmentGridStep, - getWallAngleSnapStep, isSegmentLongEnough, - snapPointTo45Degrees, snapPointToGrid, type WallPlanPoint, } from '../wall/wall-drafting' @@ -132,7 +132,9 @@ export function snapFenceDraftPoint(args: { start?: FencePlanPoint angleSnap?: boolean ignoreFenceIds?: string[] - /** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */ + bypassSnap?: boolean + magnetic?: boolean + /** Override the grid step. */ step?: number /** * Optional grid-snap function. When provided, replaces the default @@ -142,17 +144,45 @@ export function snapFenceDraftPoint(args: { */ gridSnap?: (point: FencePlanPoint) => FencePlanPoint }): FencePlanPoint { - const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step, gridSnap } = args + const { + point, + walls, + fences, + start, + angleSnap = false, + ignoreFenceIds, + bypassSnap = false, + magnetic = true, + step, + gridSnap, + } = args + if (bypassSnap) return point + const gridStep = step ?? getSegmentGridStep() - const angleStep = getWallAngleSnapStep(gridStep) - const basePoint = + + // Magnetic endpoint snap must beat the angle lock, and the lock can pull + // the cursor far enough off an endpoint that probing the locked point + // would never engage — so under the lock, probe from the RAW cursor + // first (mirrors `snapWallDraftPointDetailed`'s special-point pre-pass). + if (start && angleSnap) { + const rawTarget = + magnetic && + (findFenceSnapTarget(point, fences, ignoreFenceIds) ?? findWallSnapTarget(point, walls)) + if (rawTarget) return rawTarget + } + + // The angle path snaps the distance ALONG the 15° ray — a scalar, the + // same in world and local frames — so the `gridSnap` world-grid override + // only applies when the angle lock is off. + const basePoint: FencePlanPoint = start && angleSnap - ? snapPointTo45Degrees(start, point, gridStep, angleStep, gridSnap) + ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, gridStep)] : gridSnap ? gridSnap(point) : snapPointToGrid(point, gridStep) - const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds) + if (!magnetic) return basePoint + const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds) return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint } diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 4a6f4a7d..74bbc457 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -113,10 +113,14 @@ export const floorStrategy = { // is rotated; then project the world point back into building-local // for storage. Without this, a rotated building drags placement off // the world grid. - const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX) - const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ) - const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0) - const [x, z] = local + const bypassSnap = event.nativeEvent?.shiftKey === true + const [x, z] = bypassSnap + ? [event.localPosition[0], event.localPosition[2]] + : snapWorldXZForActiveBuilding( + snapToGrid(event.position[0], swapDims ? dimZ : dimX), + snapToGrid(event.position[2], swapDims ? dimX : dimZ), + 0, + ).local const y = ctx.gridPosition.y return { @@ -197,9 +201,10 @@ export const wallStrategy = { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const x = snapToHalf(event.localPosition[0]) - const y = snapToHalf(event.localPosition[1]) - const z = snapToHalf(event.localPosition[2]) + const bypassSnap = event.nativeEvent?.shiftKey === true + const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) + const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) + const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2]) // Get auto-adjusted Y position from validator const rawDims = ctx.draftItem @@ -231,11 +236,13 @@ export const wallStrategy = { }, cursorRotationY: cursorRotation, gridPosition: [x, adjustedY, z], - cursorPosition: [ - snapToHalf(event.position[0]), - snapToHalf(event.position[1]), - snapToHalf(event.position[2]), - ], + cursorPosition: bypassSnap + ? [event.position[0], event.position[1], event.position[2]] + : [ + snapToHalf(event.position[0]), + snapToHalf(event.position[1]), + snapToHalf(event.position[2]), + ], stopPropagation: true, } }, @@ -258,9 +265,10 @@ export const wallStrategy = { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const snappedX = snapToHalf(event.localPosition[0]) - const snappedY = snapToHalf(event.localPosition[1]) - const snappedZ = snapToHalf(event.localPosition[2]) + const bypassSnap = event.nativeEvent?.shiftKey === true + const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) + const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) + const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2]) // Get auto-adjusted Y position from validator const validation = validators.canPlaceOnWall( @@ -278,11 +286,13 @@ export const wallStrategy = { return { gridPosition: [snappedX, adjustedY, snappedZ], - cursorPosition: [ - snapToHalf(event.position[0]), - snapToHalf(event.position[1]), - snapToHalf(event.position[2]), - ], + cursorPosition: bypassSnap + ? [event.position[0], event.position[1], event.position[2]] + : [ + snapToHalf(event.position[0]), + snapToHalf(event.position[1]), + snapToHalf(event.position[2]), + ], cursorRotationY: cursorRotation, nodeUpdate: { position: [snappedX, adjustedY, snappedZ], @@ -403,8 +413,8 @@ function resolveRoofWallTarget( const dims = getGridAlignedDimensions(rawDims, attachTo) const [width, height] = dims - const u = snapToHalf(hit.u) - const centerV = snapToHalf(hit.v) + height / 2 + const u = shiftFree ? hit.u : snapToHalf(hit.u) + const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2 const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height) if (!fitted && !shiftFree) return null const finalU = fitted?.u ?? u @@ -604,8 +614,13 @@ export const ceilingStrategy = { // Ceiling items are stored in ceiling-local coordinates, so snapping must // use the ceiling hit's local position rather than world position. - const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) - const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) + const bypassSnap = event.nativeEvent?.shiftKey === true + const x = bypassSnap + ? event.localPosition[0] + : snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) + const z = bypassSnap + ? event.localPosition[2] + : snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) // Recessed fixtures seat flush with the ceiling plane (body rising into the // void above); everything else hangs its full height below the ceiling. const seatY = ctx.asset.recessed ? 0 : -itemHeight @@ -638,8 +653,13 @@ export const ceilingStrategy = { const rotY = ctx.draftItem.rotation?.[1] ?? 0 const swapDims = Math.abs(Math.sin(rotY)) > 0.9 - const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) - const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) + const bypassSnap = event.nativeEvent?.shiftKey === true + const x = bypassSnap + ? event.localPosition[0] + : snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) + const z = bypassSnap + ? event.localPosition[2] + : snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) // Recessed fixtures seat flush with the ceiling plane (body rising into the // void above); everything else hangs its full height below the ceiling. const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight @@ -750,8 +770,9 @@ export const itemSurfaceStrategy = { const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) if (surfaceHeight === null) return null - const x = snapToGrid(localPos.x, ourDims[0]) - const z = snapToGrid(localPos.z, ourDims[2]) + const bypassSnap = event.nativeEvent?.shiftKey === true + const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) + const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const y = surfaceHeight const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) @@ -801,8 +822,9 @@ export const itemSurfaceStrategy = { const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) if (surfaceHeight === null) return null - const x = snapToGrid(localPos.x, ourDims[0]) - const z = snapToGrid(localPos.z, ourDims[2]) + const bypassSnap = event.nativeEvent?.shiftKey === true + const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) + const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const y = surfaceHeight const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) @@ -901,8 +923,9 @@ export const shelfSurfaceStrategy = { const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) if (rowY === null) return null - const x = snapToGrid(localPos.x, ourDims[0]) - const z = snapToGrid(localPos.z, ourDims[2]) + const bypassSnap = event.nativeEvent?.shiftKey === true + const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) + const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) @@ -945,8 +968,9 @@ export const shelfSurfaceStrategy = { const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) if (rowY === null) return null - const x = snapToGrid(localPos.x, ourDims[0]) - const z = snapToGrid(localPos.z, ourDims[2]) + const bypassSnap = event.nativeEvent?.shiftKey === true + const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) + const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) return { diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index e47f2665..179f7b5e 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -683,11 +683,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // item's edge, snap and publish a guide. The guide connects to the // nearest real corner of the candidate (resolver tie-break), so the dot // always sits on an actual point. The delta is applied to BOTH the grid - // and cursor positions below. Alt bypasses. + // and cursor positions below. Alt bypasses alignment; Shift bypasses all snap. const draft = draftNode.current let alignX = 0 let alignZ = 0 - const bypassAlign = floorEvent.nativeEvent?.altKey === true + const bypassSnap = floorEvent.nativeEvent?.shiftKey === true + const bypassAlign = floorEvent.nativeEvent?.altKey === true || bypassSnap if (!bypassAlign && draft) { alignmentCandidates ??= collectAlignmentAnchors( useScene.getState().nodes, @@ -721,6 +722,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Play snap sound when grid position changes if ( + !bypassSnap && previousGridPos && (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2]) ) { @@ -866,7 +868,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.z !== result.gridPosition[2] // Play snap sound when grid position changes - if (posChanged) { + if (event.nativeEvent?.shiftKey !== true && posChanged) { sfxEmitter.emit('sfx:grid-snap') } @@ -1035,7 +1037,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.z !== result.gridPosition[2] - if (posChanged) { + if (!shiftFreeRef.current && posChanged) { sfxEmitter.emit('sfx:grid-snap') } @@ -1128,8 +1130,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea event.position[1], event.position[2], ) - const wx = Math.round(buildingLocalPoint.x * 2) / 2 - const wz = Math.round(buildingLocalPoint.z * 2) / 2 + const bypassSnap = event.nativeEvent?.shiftKey === true + const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2 + const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2 const floorPos: [number, number, number] = [wx, 0, wz] Object.assign(placementState.current, { @@ -1429,7 +1432,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.z !== result.gridPosition[2] - if (posChanged) { + if (event.nativeEvent?.shiftKey !== true && posChanged) { sfxEmitter.emit('sfx:grid-snap') } diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 5ac09d65..40078ee8 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -40,8 +40,8 @@ const snapToGridStep = (value: number) => { return Math.round(value / step) * step } -/** 90° steps, matching the GLB item placement rotation. */ -const ROTATION_STEP = Math.PI / 2 +/** 45° steps, matching the GLB item placement rotation. */ +const ROTATION_STEP = Math.PI / 4 /** Figma-style alignment-snap threshold (meters), matching the 2D * floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull @@ -286,7 +286,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { original: [originalPosition[0], originalPosition[2]], anchor: dragAnchorRef.current, mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative', - snap: snapToGridStep, + snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep, }) dragAnchorRef.current = resolved.anchor let [x, z] = resolved.point @@ -295,8 +295,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // moving item's edge lines up (on X or Z) with another item's edge, // snap and publish a guide. The guide connects to the nearest real // corner of the candidate (resolver tie-break), so the dot always sits - // on an actual point. Alt bypasses. - const bypass = event.nativeEvent?.altKey === true + // on an actual point. Alt bypasses alignment; Shift bypasses all snap. + const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignment({ moving: movingFootprintAnchors(node, x, z, rotationRef.current), @@ -338,7 +338,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { markMovedNodeDirty() const prev = previousSnapRef.current - if (!prev || prev[0] !== x || prev[1] !== z) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) { sfxEmitter.emit('sfx:grid-snap') previousSnapRef.current = [x, z] } @@ -457,7 +457,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (typeof direct === 'function') direct.call(event) } - // R / T rotate the dragged node about Y in 90° steps — matching the GLB + // R / T rotate the dragged node about Y in 45° steps — matching the GLB // item placement keys (and the "Rotate" hints the move HUD shows). Applied // imperatively + mirrored to the live transform; committed on drop. const onKeyDown = (e: KeyboardEvent) => { diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 1bd9d8af..9b30cf23 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -242,17 +242,20 @@ export const RoofTool: React.FC = () => { // World-grid snap projected into building-local; rotated buildings // used to drag every roof corner off the visible grid. - const snapped = snapWorldXZForActiveBuilding( - event.position[0], - event.position[2], - useEditor.getState().gridSnapStep, - ).local + const bypassSnap = event.nativeEvent?.shiftKey === true + const snapped: [number, number] = bypassSnap + ? [event.localPosition[0], event.localPosition[2]] + : snapWorldXZForActiveBuilding( + event.position[0], + event.position[2], + useEditor.getState().gridSnapStep, + ).local const [gridX, gridZ] = alignPoint( snapped[0], snapped[1], event.localPosition[0], event.localPosition[2], - event.nativeEvent?.altKey === true, + event.nativeEvent?.altKey === true || bypassSnap, ) const y = event.localPosition[1] @@ -262,6 +265,7 @@ export const RoofTool: React.FC = () => { cursorRef.current.position.set(gridX, gridY, gridZ) if ( + !bypassSnap && corner1Ref.current && previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) @@ -287,17 +291,20 @@ export const RoofTool: React.FC = () => { // World-grid snap projected into building-local; rotated buildings // used to drag every roof corner off the visible grid. - const snapped = snapWorldXZForActiveBuilding( - event.position[0], - event.position[2], - useEditor.getState().gridSnapStep, - ).local + const bypassSnap = event.nativeEvent?.shiftKey === true + const snapped: [number, number] = bypassSnap + ? [event.localPosition[0], event.localPosition[2]] + : snapWorldXZForActiveBuilding( + event.position[0], + event.position[2], + useEditor.getState().gridSnapStep, + ).local const [gridX, gridZ] = alignPoint( snapped[0], snapped[1], event.localPosition[0], event.localPosition[2], - event.nativeEvent?.altKey === true, + event.nativeEvent?.altKey === true || bypassSnap, ) const y = event.localPosition[1] diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 5d9472ec..fcc1e7fb 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -746,7 +746,10 @@ export const PolygonEditor: React.FC = ({ const onGridMove = (event: GridEvent) => { const point = levelNode ? event.localPosition : event.position const rawPoint: [number, number] = [point[0], point[2]] - const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])] + const bypassSnap = event.nativeEvent.shiftKey === true + const gridPoint: [number, number] = bypassSnap + ? rawPoint + : [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])] const newPosition = dragState?.isDragging && resolvePlanPoint ? resolvePlanPoint({ @@ -763,6 +766,7 @@ export const PolygonEditor: React.FC = ({ // Play snap sound when cursor moves to a new grid cell during drag if ( + !bypassSnap && dragState?.isDragging && previousPositionRef.current && (newPosition[0] !== previousPositionRef.current[0] || diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 6f2761bc..886abc40 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -348,18 +348,20 @@ export const StairTool: React.FC = () => { } const onGridMove = (event: GridEvent) => { + const bypassSnap = event.nativeEvent?.shiftKey === true const [gridX, gridZ] = alignPoint( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, + bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, + bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, event.localPosition[0], event.localPosition[2], - event.nativeEvent?.altKey === true, + event.nativeEvent?.altKey === true || bypassSnap, ) const position: [number, number, number] = [gridX, 0, gridZ] lastCanonicalPositionRef.current = position applyDraftPreview(position, rotationRef.current) if ( + !bypassSnap && previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) ) { @@ -370,12 +372,13 @@ export const StairTool: React.FC = () => { } const getAlignedGridPosition = (event: GridEvent): [number, number, number] => { + const bypassSnap = event.nativeEvent?.shiftKey === true const [gridX, gridZ] = alignPoint( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, + bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, + bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, event.localPosition[0], event.localPosition[2], - event.nativeEvent?.altKey === true, + event.nativeEvent?.altKey === true || bypassSnap, ) return [gridX, 0, gridZ] } diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts new file mode 100644 index 00000000..4ed03e2e --- /dev/null +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + useScene, + type WallNode, + WallNode as WallSchema, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createWallOnCurrentLevel, snapWallDraftPointDetailed } from './wall-drafting' +import type { WallPlanPoint } from './wall-snap-geometry' + +const LEVEL_ID = 'level_test' as AnyNodeId + +function makeWall(start: WallPlanPoint, end: WallPlanPoint, id: string): WallNode { + return { + ...WallSchema.parse({ start, end, name: id }), + id: id as WallNode['id'], + parentId: LEVEL_ID, + } +} + +function seedLevel(walls: WallNode[]) { + useScene.setState({ + nodes: Object.fromEntries([ + [ + LEVEL_ID, + { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: walls.map((wall) => wall.id), + level: 0, + } as AnyNode, + ], + ...walls.map((wall) => [wall.id, wall] as const), + ]), + rootNodeIds: [LEVEL_ID], + dirtyNodes: new Set(), + collections: {}, + } as never) +} + +function levelWalls(): WallNode[] { + return Object.values(useScene.getState().nodes).filter( + (node): node is WallNode => node?.type === 'wall', + ) +} + +describe('createWallOnCurrentLevel', () => { + beforeEach(() => { + useViewer.setState({ + selection: { + buildingId: 'building_test', + levelId: LEVEL_ID, + zoneId: null, + selectedIds: [], + }, + } as never) + seedLevel([makeWall([0, 0], [4, 0], 'wall_a')]) + }) + + test('endpoint near an existing corner attaches to the corner instead of splitting', () => { + const created = createWallOnCurrentLevel([2, 2], [3.99, 0]) + + expect(created?.end).toEqual([4, 0]) + const hostWall = useScene.getState().nodes['wall_a' as AnyNodeId] as WallNode | undefined + expect(hostWall?.start).toEqual([0, 0]) + expect(hostWall?.end).toEqual([4, 0]) + expect(levelWalls()).toHaveLength(2) + }) + + test('endpoint near the host start corner snaps there without splitting', () => { + const created = createWallOnCurrentLevel([2, 2], [0.015, 0]) + + expect(created?.end).toEqual([0, 0]) + expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeDefined() + expect(levelWalls()).toHaveLength(2) + }) + + test('genuine mid-wall endpoint still splits the host (T junction)', () => { + const created = createWallOnCurrentLevel([2, 2], [2, 0]) + + expect(created?.end).toEqual([2, 0]) + expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeUndefined() + const walls = levelWalls() + expect(walls).toHaveLength(3) + expect( + walls.some((wall) => wall.start[0] === 0 && wall.end[0] === 2 && wall.end[1] === 0), + ).toBe(true) + expect( + walls.some((wall) => wall.start[0] === 2 && wall.start[1] === 0 && wall.end[0] === 4), + ).toBe(true) + }) + + test('exact duplicate segment is rejected', () => { + expect(createWallOnCurrentLevel([0, 0], [4, 0])).toBeNull() + expect(levelWalls()).toHaveLength(1) + }) +}) + +describe('snapWallDraftPointDetailed', () => { + test('bypassSnap returns the raw point without endpoint or angle snap', () => { + const wall = makeWall([0, 0], [4, 0], 'wall_a') + const result = snapWallDraftPointDetailed({ + point: [3.99, 0.03], + walls: [wall], + start: [2, 2], + angleSnap: true, + bypassSnap: true, + }) + + expect(result.point).toEqual([3.99, 0.03]) + expect(result.snap).toBeNull() + }) +}) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 4491c70f..3667e1af 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -1,9 +1,11 @@ import { type AnyNode, type AnyNodeId, + DEFAULT_ANGLE_STEP, type DoorNode, getScaledDimensions, type ItemNode, + snapPointAlongAngleRay, useScene, type WallNode, WallNode as WallSchema, @@ -35,21 +37,16 @@ export { } from './wall-snap-geometry' export const WALL_GRID_STEP = 0.5 -// Smallest available grid snap. Used as a precision-mode step (Shift + -// drag) so a drag can land on values the regular grid skips. -export const WALL_FINE_GRID_STEP = 0.05 export const WALL_MIN_LENGTH = 0.01 -const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4 - -const WALL_ANGLE_SNAP_BY_GRID_STEP: Record = { - 0.5: Math.PI / 4, - 0.25: Math.PI / 8, - 0.1: Math.PI / 12, - 0.05: Math.PI / 36, -} +// An endpoint projecting within this distance of an existing wall's corner +// resolves to the corner without splitting — splitting there would mint a +// sliver segment a hair longer than `WALL_MIN_LENGTH` that no snap radius +// can ever target again. +const WALL_SPLIT_ENDPOINT_EPSILON = 0.02 type WallSplitIntersection = { - wallId: WallNode['id'] + /** `null` = snap-only outcome: resolve to `point` but split no wall. */ + wallId: WallNode['id'] | null point: WallPlanPoint } @@ -65,35 +62,6 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)] } -export function snapPointTo45Degrees( - start: WallPlanPoint, - cursor: WallPlanPoint, - step = WALL_GRID_STEP, - angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP, - /** - * Optional grid-snap callback. Lets the caller route the final - * snap through a world-XZ grid (or any other axis system) instead - * of the local-axis grid `snapPointToGrid` uses. When omitted, - * falls back to the local-axis snap at `step`. - */ - gridSnap?: (point: WallPlanPoint) => WallPlanPoint, -): WallPlanPoint { - const dx = cursor[0] - start[0] - const dz = cursor[1] - start[1] - const angle = Math.atan2(dz, dx) - const snappedAngle = Math.round(angle / angleStep) * angleStep - const distance = Math.sqrt(dx * dx + dz * dz) - const point: WallPlanPoint = [ - start[0] + Math.cos(snappedAngle) * distance, - start[1] + Math.sin(snappedAngle) * distance, - ] - return gridSnap ? gridSnap(point) : snapPointToGrid(point, step) -} - -export function getWallAngleSnapStep(step = getSegmentGridStep()): number { - return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP -} - function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] { const { id: _id, parentId: _parentId, children, ...rest } = wall @@ -140,7 +108,14 @@ function findWallIntersection( continue } - best = { wallId: wall.id, point: projected } + const nearCorner = ([wall.start, wall.end] as WallPlanPoint[]).find( + (corner) => + distanceSquared(projected, corner) <= + WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON, + ) + best = nearCorner + ? { wallId: null, point: [nearCorner[0], nearCorner[1]] } + : { wallId: wall.id, point: projected } bestDistanceSquared = candidateDistanceSquared } @@ -292,6 +267,10 @@ function splitWallIfNeeded( ): { walls: WallNode[]; point: WallPlanPoint } | null { if (!intersection) return null + if (!intersection.wallId) { + return { walls, point: intersection.point } + } + const wallToSplit = walls.find((wall) => wall.id === intersection.wallId) if (!wallToSplit) { return { walls, point: intersection.point } @@ -331,7 +310,8 @@ type SnapWallDraftArgs = { start?: WallPlanPoint angleSnap?: boolean ignoreWallIds?: string[] - /** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */ + bypassSnap?: boolean + /** Override the grid step. */ step?: number /** * Magnetic snapping to existing wall geometry (corners, midpoints, @@ -358,12 +338,15 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn start, angleSnap = false, ignoreWallIds, + bypassSnap = false, step: overrideStep, magnetic = true, gridSnap, snapRadii, } = args + if (bypassSnap) return { point, snap: null } + // Discrete special points (corner / midpoint / crossing) are taken from the // raw cursor so an interim grid snap can't mask them. A corner always wins, // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`. @@ -373,10 +356,12 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn } const step = overrideStep ?? getSegmentGridStep() - const angleStep = getWallAngleSnapStep(step) - const basePoint = + // The angle path snaps the distance ALONG the 15° ray — a scalar, the + // same in world and local frames — so the `gridSnap` world-grid override + // only applies when the angle lock is off. + const basePoint: WallPlanPoint = start && angleSnap - ? snapPointTo45Degrees(start, point, step, angleStep, gridSnap) + ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, step)] : gridSnap ? gridSnap(point) : snapPointToGrid(point, step) diff --git a/packages/editor/src/components/tools/zone/zone-tool.tsx b/packages/editor/src/components/tools/zone/zone-tool.tsx index 7ce8f0ae..5b74fb4e 100644 --- a/packages/editor/src/components/tools/zone/zone-tool.tsx +++ b/packages/editor/src/components/tools/zone/zone-tool.tsx @@ -1,4 +1,12 @@ -import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pascal-app/core' +import { + DEFAULT_ANGLE_STEP, + emitter, + type GridEvent, + type LevelNode, + snapPointAlongAngleRay, + useScene, + ZoneNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' @@ -10,42 +18,6 @@ import { CursorSphere } from '../shared/cursor-sphere' const Y_OFFSET = 0.02 -/** - * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point - */ -const calculateSnapPoint = ( - lastPoint: [number, number], - currentPoint: [number, number], -): [number, number] => { - const [x1, y1] = lastPoint - const [x, y] = currentPoint - - const dx = x - x1 - const dy = y - y1 - const absDx = Math.abs(dx) - const absDy = Math.abs(dy) - - // Calculate distances to horizontal, vertical, and diagonal lines - const horizontalDist = absDy - const verticalDist = absDx - const diagonalDist = Math.abs(absDx - absDy) - - // Find the minimum distance to determine which axis to snap to - const minDist = Math.min(horizontalDist, verticalDist, diagonalDist) - - if (minDist === diagonalDist) { - // Snap to 45° diagonal - const diagonalLength = Math.min(absDx, absDy) - return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength] - } - if (minDist === horizontalDist) { - // Snap to horizontal - return [x, y1] - } - // Snap to vertical - return [x1, y] -} - /** * Creates a zone with the given polygon points */ @@ -93,6 +65,7 @@ export const ZoneTool: React.FC = () => { const pointsRef = useRef>([]) const previousSnappedPointRef = useRef<[number, number] | null>(null) const levelYRef = useRef(0) // Track current level Y position + const shiftPressed = useRef(false) const currentLevelId = useViewer((state) => state.selection.levelId) const setTool = useEditor((state) => state.setTool) @@ -107,11 +80,30 @@ export const ZoneTool: React.FC = () => { if (!currentLevelId) return let cursorPosition: [number, number] = [0, 0] + let rawCursorPosition: [number, number] = [0, 0] // Initialize line geometries mainLineRef.current.geometry = new BufferGeometry() closingLineRef.current.geometry = new BufferGeometry() + // 15° angle snap from the last vertex by default. Shift bypasses all snap. + // Distance snaps along the ray so the vertex lands on + // grid-multiple lengths without leaving the ray. + const snapDraftPoint = ( + lastPoint: [number, number], + gridPoint: [number, number], + rawPoint: [number, number], + ): [number, number] => { + if (shiftPressed.current) return rawPoint + const [x, z] = snapPointAlongAngleRay( + lastPoint, + rawPoint, + DEFAULT_ANGLE_STEP, + useEditor.getState().gridSnapStep, + ) + return [x, z] + } + const updateLines = () => { const points = pointsRef.current const y = levelYRef.current + Y_OFFSET @@ -128,7 +120,7 @@ export const ZoneTool: React.FC = () => { // Add cursor point const lastPoint = points[points.length - 1] if (lastPoint) { - const snapped = calculateSnapPoint(lastPoint, cursorPosition) + const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition) if (isValidPoint(snapped)) { linePoints.push(new Vector3(snapped[0], y, snapped[1])) } @@ -146,7 +138,7 @@ export const ZoneTool: React.FC = () => { // Update closing line (from cursor back to first point) const firstPoint = points[0] if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) { - const snapped = calculateSnapPoint(lastPoint, cursorPosition) + const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition) if (isValidPoint(snapped)) { const closingPoints = [ new Vector3(snapped[0], y, snapped[1]), @@ -167,7 +159,7 @@ export const ZoneTool: React.FC = () => { let cursorPt: [number, number] | null = null if (lastPoint) { - cursorPt = calculateSnapPoint(lastPoint, cursorPosition) + cursorPt = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition) } else if (points.length === 0) { cursorPt = cursorPosition } @@ -181,22 +173,27 @@ export const ZoneTool: React.FC = () => { // World-grid snap projected into building-local; rotated buildings // used to pull the snap off the visible grid lines. - const [gridX, gridZ] = snapWorldXZForActiveBuilding( - event.position[0], - event.position[2], - useEditor.getState().gridSnapStep, - ).local + const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true + const [gridX, gridZ] = bypassSnap + ? [event.localPosition[0], event.localPosition[2]] + : snapWorldXZForActiveBuilding( + event.position[0], + event.position[2], + useEditor.getState().gridSnapStep, + ).local cursorPosition = [gridX, gridZ] + rawCursorPosition = [event.localPosition[0], event.localPosition[2]] levelYRef.current = event.localPosition[1] - // If we have points, snap to axis from last point + // If we have points, snap to the 15° ray from the last point const lastPoint = pointsRef.current[pointsRef.current.length - 1] const displayPoint = lastPoint - ? calculateSnapPoint(lastPoint, cursorPosition) + ? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition) : cursorPosition // Play snap sound when the snapped position changes during drawing if ( + !bypassSnap && pointsRef.current.length > 0 && previousSnappedPointRef.current && (displayPoint[0] !== previousSnappedPointRef.current[0] || @@ -214,17 +211,23 @@ export const ZoneTool: React.FC = () => { const onGridClick = (event: GridEvent) => { if (!currentLevelId) return - const [gridX, gridZ] = snapWorldXZForActiveBuilding( - event.position[0], - event.position[2], - useEditor.getState().gridSnapStep, - ).local + const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true + const [gridX, gridZ] = bypassSnap + ? [event.localPosition[0], event.localPosition[2]] + : snapWorldXZForActiveBuilding( + event.position[0], + event.position[2], + useEditor.getState().gridSnapStep, + ).local let clickPoint: [number, number] = [gridX, gridZ] - // Snap to axis from last point + // Snap to the 15° ray from the last point const lastPoint = pointsRef.current[pointsRef.current.length - 1] if (lastPoint) { - clickPoint = calculateSnapPoint(lastPoint, clickPoint) + clickPoint = snapDraftPoint(lastPoint, clickPoint, [ + event.localPosition[0], + event.localPosition[2], + ]) } // Check if clicking on the first point to close the shape @@ -267,12 +270,28 @@ export const ZoneTool: React.FC = () => { } } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftPressed.current = true + } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftPressed.current = false + } + const onWindowBlur = () => { + shiftPressed.current = false + } + document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onWindowBlur) + // Subscribe to events emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('grid:double-click', onGridDoubleClick) return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onWindowBlur) emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index a2a03356..c27d0dde 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -80,8 +80,13 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ shortcuts: [ { keys: ['Shift'], - action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings', - note: 'Hold while drawing.', + action: 'Draw at any angle, bypassing the default 15° angle snap', + note: 'Hold while drawing walls, fences, slabs, ceilings, and zones.', + }, + { + keys: ['Shift'], + action: 'Rotate freely, bypassing the default 15° rotation snap', + note: 'Hold while dragging a rotate handle.', }, ], }, diff --git a/packages/editor/src/hooks/use-drag-action.ts b/packages/editor/src/hooks/use-drag-action.ts index 97674f79..b0c95237 100644 --- a/packages/editor/src/hooks/use-drag-action.ts +++ b/packages/editor/src/hooks/use-drag-action.ts @@ -19,7 +19,10 @@ import { useEffect, useRef } from 'react' const sceneApi = createSceneApi(useScene) function modifiersFromGridEvent(event: GridEvent): Modifiers { - const ne = event.nativeEvent?.nativeEvent as Partial | undefined + // Both grid-event emit paths (use-grid-events.ts and the floorplan panel) + // store the raw DOM event directly at `.nativeEvent` — there is no second + // `.nativeEvent` hop. + const ne = event.nativeEvent as Partial | undefined return { shift: ne?.shiftKey ?? false, alt: ne?.altKey ?? false, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 93b69ca7..6e0b0dde 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -104,7 +104,6 @@ export { snapScalarToGrid, snapWallDraftPoint, snapWallDraftPointDetailed, - WALL_FINE_GRID_STEP, WALL_GRID_STEP, type WallDraftSnapKind, type WallDraftSnapResult, @@ -299,6 +298,7 @@ export { usePaletteViewRegistry, } from './store/use-palette-view-registry' export { default as usePlacementPreview } from './store/use-placement-preview' +export { default as useSegmentDraftChain } from './store/use-segment-draft-chain' export { useUploadStore } from './store/use-upload' export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts' export { diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts index 95693425..4d48a6b1 100644 --- a/packages/editor/src/lib/surface-plan-snap.ts +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -43,6 +43,7 @@ export type SurfacePlanSnapInput = { candidates?: readonly AlignmentAnchor[] threshold?: number altKey?: boolean + shiftKey?: boolean magnetic?: boolean align?: boolean highlightWalls?: boolean @@ -171,6 +172,12 @@ export function clearSurfacePlanSnapFeedback() { } export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult { + if (input.shiftKey) { + useWallSnapIndicator.getState().clear() + useAlignmentGuides.getState().clear() + return { point: input.rawPoint, wallSnap: null, guides: [], wallIds: [] } + } + const nodes = input.nodes ?? useScene.getState().nodes const walls = getLevelWalls(nodes, input.levelId, input.walls) const fallbackPoint = input.fallbackPoint diff --git a/packages/editor/src/store/use-segment-draft-chain.ts b/packages/editor/src/store/use-segment-draft-chain.ts new file mode 100644 index 00000000..b578f0ca --- /dev/null +++ b/packages/editor/src/store/use-segment-draft-chain.ts @@ -0,0 +1,28 @@ +// Ephemeral store for the wall / fence tools' click-chaining start points. +// The 3D tools (`@pascal-app/nodes` wall/tool.tsx, fence/tool.tsx) own node +// creation for both views; after each chained commit they publish the +// created segment's resolved end here so the 2D floor-plan draft chains its +// next segment from the same point instead of re-deriving it through a +// different snap pipeline. Cleared on cancel, single-segment commit, and +// unmount — never persisted, never in undo history. + +import { create } from 'zustand' +import type { WallPlanPoint } from '../components/tools/wall/wall-snap-geometry' + +type SegmentKind = 'wall' | 'fence' + +type SegmentDraftChainState = { + wall: WallPlanPoint | null + fence: WallPlanPoint | null + setChainStart(kind: SegmentKind, point: WallPlanPoint | null): void + clear(kind: SegmentKind): void +} + +const useSegmentDraftChain = create((set) => ({ + wall: null, + fence: null, + setChainStart: (kind, point) => set({ [kind]: point }), + clear: (kind) => set({ [kind]: null }), +})) + +export default useSegmentDraftChain diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index 6f1cfd07..4a8b1da5 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -68,7 +68,10 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 - if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { + if ( + event.nativeEvent?.shiftKey !== true && + (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) + ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index 814434db..4290d436 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' import { boxVentDefinition } from './definition' import BoxVentPreview from './preview' @@ -37,6 +37,7 @@ const BoxVentTool = () => { const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) const [previewYaw, setPreviewYaw] = useState(0) + const [previewRotation, setPreviewRotation] = useState(0) const lastSnapRef = useRef<[number, number] | null>(null) // Default-shaped preview node — matches what the commit will create. @@ -46,9 +47,9 @@ const BoxVentTool = () => { ...boxVentDefinition.defaults(), name: 'Box Vent', position: [0, 0, 0], - rotation: 0, + rotation: previewRotation, }), - [], + [previewRotation], ) useEffect(() => { @@ -70,7 +71,7 @@ const BoxVentTool = () => { const sx = Math.round(wx * 20) / 20 const sz = Math.round(wz * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } @@ -81,6 +82,7 @@ const BoxVentTool = () => { const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) + setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) event.stopPropagation() } @@ -100,7 +102,7 @@ const BoxVentTool = () => { name: 'Box Vent', roofSegmentId: hit.segment.id, position: [hit.localX, hit.localY, hit.localZ], - rotation: 0, + rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), }) state.createNode(vent, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) diff --git a/packages/nodes/src/building/move-tool.tsx b/packages/nodes/src/building/move-tool.tsx index c1196e77..91333f46 100644 --- a/packages/nodes/src/building/move-tool.tsx +++ b/packages/nodes/src/building/move-tool.tsx @@ -93,7 +93,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { return } - const ROTATION_STEP = Math.PI / 2 + const ROTATION_STEP = Math.PI / 4 let rotationDelta = 0 if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP @@ -121,14 +121,16 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { } const onGridMove = (event: GridEvent) => { - const rawX = Math.round(event.position[0] * 2) / 2 - const rawZ = Math.round(event.position[2] * 2) / 2 + const bypassSnap = event.nativeEvent?.shiftKey === true + const rawX = bypassSnap ? event.position[0] : Math.round(event.position[0] * 2) / 2 + const rawZ = bypassSnap ? event.position[2] : Math.round(event.position[2] * 2) / 2 const anchor = dragAnchorRef.current ?? [rawX, rawZ] dragAnchorRef.current = anchor const gridX = originalCenter[0] + (rawX - anchor[0]) const gridZ = originalCenter[1] + (rawZ - anchor[1]) if ( + !bypassSnap && previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) ) { diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index 9093b7fe..63f27ce0 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -126,6 +126,7 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = levelId: ceilingLevelId, excludeId: ceilingId, altKey: context.nativeEvent?.altKey === true, + shiftKey: context.nativeEvent?.shiftKey === true, }).point, [ceilingId, ceilingLevelId], ) diff --git a/packages/nodes/src/ceiling/floorplan-affordances.ts b/packages/nodes/src/ceiling/floorplan-affordances.ts index 176906cc..d1802097 100644 --- a/packages/nodes/src/ceiling/floorplan-affordances.ts +++ b/packages/nodes/src/ceiling/floorplan-affordances.ts @@ -29,6 +29,7 @@ const ceilingSnapOptions = { excludeId: node.id, nodes: sceneNodes, altKey: modifiers.altKey, + shiftKey: modifiers.shiftKey, }).point }, } diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 3e255313..3914db44 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -147,10 +147,12 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const onGridMove = (event: GridEvent) => { if (isFloorplanSourcedEvent(event)) return - const localX = snap(event.localPosition[0]) - const localZ = snap(event.localPosition[2]) + const bypassSnap = event.nativeEvent?.shiftKey === true + const localX = bypassSnap ? event.localPosition[0] : snap(event.localPosition[0]) + const localZ = bypassSnap ? event.localPosition[2] : snap(event.localPosition[2]) if ( + !bypassSnap && previousGridPosRef.current && (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { @@ -166,8 +168,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { // Figma-style alignment snap: align the ceiling's translated polygon // vertices to other objects' anchors; fold the snap into the delta and - // publish a guide. Alt bypasses. - const bypass = event.nativeEvent?.altKey === true + // publish a guide. Alt bypasses alignment; Shift bypasses all snap. + const bypass = event.nativeEvent?.altKey === true || bypassSnap if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignment({ moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)), diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index d70c560f..f20bb607 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -1,6 +1,13 @@ 'use client' -import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' +import { + DEFAULT_ANGLE_STEP, + emitter, + type GridEvent, + type LevelNode, + snapPointAlongAngleRay, + useScene, +} from '@pascal-app/core' import { CursorSphere, clearCeilingSnapFeedback, @@ -22,34 +29,12 @@ import { CeilingNode } from './schema' * Multi-click polygon drawing at the ceiling height (2.52m default) * with a vertical TSL-gradient connector + ground-shadow lines so the * draft is visible against both the ceiling plane and the floor. - * Shift defeats the axis/45° snap during drag. + * Shift defeats the 15° angle snap during drag. */ const CEILING_HEIGHT = 2.52 const GRID_OFFSET = 0.02 -function calculateSnapPoint( - lastPoint: [number, number], - currentPoint: [number, number], -): [number, number] { - const [x1, y1] = lastPoint - const [x, y] = currentPoint - const dx = x - x1 - const dy = y - y1 - const absDx = Math.abs(dx) - const absDy = Math.abs(dy) - const horizontalDist = absDy - const verticalDist = absDx - const diagonalDist = Math.abs(absDx - absDy) - const minDist = Math.min(horizontalDist, verticalDist, diagonalDist) - if (minDist === diagonalDist) { - const diagonalLength = Math.min(absDx, absDy) - return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength] - } - if (minDist === horizontalDist) return [x, y1] - return [x1, y] -} - function commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string { const { createNode, nodes } = useScene.getState() const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length @@ -107,26 +92,38 @@ export const CeilingTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && gridCursorRef.current)) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] + const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true const gridX = Math.round(rawPoint[0] * 2) / 2 const gridZ = Math.round(rawPoint[1] * 2) / 2 - const gridPosition: [number, number] = [gridX, gridZ] + const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const ceilingY = event.localPosition[1] + CEILING_HEIGHT const gridY = event.localPosition[1] + GRID_OFFSET const lastPoint = points[points.length - 1] - const orthoPoint = - shiftPressed.current || !lastPoint + // 15° angle snap from the raw cursor (matching the 2D floorplan + // pipeline) with the distance snapped along the ray to the grid step. + const orthoPoint: [number, number] = + bypassSnap || !lastPoint ? gridPosition - : calculateSnapPoint(lastPoint, gridPosition) + : [ + ...snapPointAlongAngleRay( + lastPoint, + rawPoint, + DEFAULT_ANGLE_STEP, + useEditor.getState().gridSnapStep, + ), + ] const displayPoint = resolveCeilingPlanPointSnap({ rawPoint, fallbackPoint: orthoPoint, levelId: currentLevelId, altKey: event.nativeEvent?.altKey === true, + shiftKey: bypassSnap, }).point setSnappedCursorPosition(displayPoint) if ( + !bypassSnap && points.length > 0 && previousSnappedPointRef.current && (displayPoint[0] !== previousSnappedPointRef.current[0] || @@ -186,8 +183,12 @@ export const CeilingTool: React.FC = () => { const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false } + const onWindowBlur = () => { + shiftPressed.current = false + } document.addEventListener('keydown', onKeyDown) document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onWindowBlur) emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) @@ -197,6 +198,7 @@ export const CeilingTool: React.FC = () => { return () => { document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onWindowBlur) emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index c72908a5..c635bc03 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -97,7 +97,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index 522caf28..5b319ca2 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -88,7 +88,7 @@ const ChimneyTool = () => { const sx = Math.round(wx * 20) / 20 const sz = Math.round(wz * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } diff --git a/packages/nodes/src/column/floorplan-move.ts b/packages/nodes/src/column/floorplan-move.ts index 73572f84..4ae6e1d0 100644 --- a/packages/nodes/src/column/floorplan-move.ts +++ b/packages/nodes/src/column/floorplan-move.ts @@ -63,7 +63,7 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget = ({ nod return Math.round(value / step) * step } const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint - // Figma-style alignment layered on the grid snap (Alt bypasses). + // Figma-style alignment layered on the grid snap (Alt bypasses alignment; Shift all snap). const { point: snapped } = applyFloorplanAlignment( gridSnapped, movingFootprintAnchors( @@ -73,13 +73,13 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget = ({ nod rotationY, ), candidates, - { bypass: modifiers.altKey }, + { bypass: modifiers.altKey || modifiers.shiftKey }, ) const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] lastPosition = next const snapKey = `${snapped[0]},${snapped[1]}` - if (snapKey !== lastSnapKey) { + if (!modifiers.shiftKey && snapKey !== lastSnapKey) { triggerSFX('sfx:grid-snap') lastSnapKey = snapKey } diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index f9a31454..f2d645f4 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -50,8 +50,8 @@ const snapToGridStep = (value: number) => { return Math.round(value / step) * step } -/** 90° steps, matching the GLB item / shelf placement rotation. */ -const ROTATION_STEP = Math.PI / 2 +/** 45° steps, matching the generic move tool's R/T rotation. */ +const ROTATION_STEP = Math.PI / 4 /** Figma-style alignment-snap threshold (meters), matching the other tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 @@ -124,15 +124,15 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { original: [node.position[0], node.position[2]], anchor: dragAnchor, mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative', - snap: snapToGridStep, + snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep, }) dragAnchor = resolved.anchor let [x, z] = resolved.point - // Figma-style alignment snap on top of grid snap; Alt bypasses. The + // Figma-style alignment snap on top of grid snap; Alt bypasses alignment; Shift all snap. The // guide connects to the candidate's nearest real anchor (resolver // tie-break), so the dot always sits on an actual point. - const bypass = event.nativeEvent?.altKey === true + const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignment({ moving: movingFootprintAnchors(node, x, z, rotationY), @@ -151,7 +151,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { applyPreview([x, 0, z]) } - // R / T rotate the dragged column about Y in 90° steps (matches the move + // R / T rotate the dragged column about Y in 45° steps (matches the move // HUD's "Rotate" hints), committed on drop. const onKeyDown = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey) return diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 6b221b4a..c8e1bc7b 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -87,7 +87,8 @@ const ColumnTool = () => { rawZ: event.localPosition[2], gridStep: useEditor.getState().gridSnapStep, candidates: alignmentCandidates, - bypassAlignment: event.nativeEvent?.altKey === true, + bypassAlignment: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassGrid: event.nativeEvent?.shiftKey === true, }) useAlignmentGuides.getState().set(guides) @@ -107,7 +108,10 @@ const ColumnTool = () => { usePlacementPreview.getState().set({ ...previewNode, position }) const prev = previousSnapRef.current - if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { + if ( + event.nativeEvent?.shiftKey !== true && + (!prev || prev[0] !== position[0] || prev[1] !== position[2]) + ) { triggerSFX('sfx:grid-snap') previousSnapRef.current = [position[0], position[2]] } @@ -116,7 +120,12 @@ const ColumnTool = () => { const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => { const position = lastCursorRef.current ?? - getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep) + getLevelLocalSnappedPosition( + activeLevelId, + event, + useEditor.getState().gridSnapStep, + event.nativeEvent?.shiftKey === true, + ) const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) useScene.getState().createNode(column, activeLevelId) diff --git a/packages/nodes/src/cupola/move-tool.tsx b/packages/nodes/src/cupola/move-tool.tsx index 2d6f9b27..40e50b30 100644 --- a/packages/nodes/src/cupola/move-tool.tsx +++ b/packages/nodes/src/cupola/move-tool.tsx @@ -66,7 +66,10 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 - if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { + if ( + event.nativeEvent?.shiftKey !== true && + (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) + ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index 857a3f75..9b6e1996 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -64,7 +64,7 @@ const CupolaTool = () => { const sx = Math.round(wx * 20) / 20 const sz = Math.round(wz * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index d6433a7f..d488a791 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -83,16 +83,17 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // Figma-style along-wall alignment first (edge-to-edge with other // openings / wall ends); it competes with — and wins over — the 0.5m // grid snap. Falls back to the grid snap when nothing aligns. Alt - // bypasses; Shift drops the grid snap for fine positioning. - const neighborX = modifiers.altKey - ? null - : snapLocalXToNeighbors({ - wall: hit.wall, - localX: hit.localX, - width: node.width, - selfId: node.id as AnyNodeId, - nodes, - }) + // bypasses alignment; Shift bypasses all snap. + const neighborX = + modifiers.altKey || modifiers.shiftKey + ? null + : snapLocalXToNeighbors({ + wall: hit.wall, + localX: hit.localX, + width: node.width, + selfId: node.id as AnyNodeId, + nodes, + }) const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 2062f464..ece90ca1 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -180,7 +180,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => rawLocalX: targetLocalX, width: movingDoorNode.width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, + bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassSnap: event.nativeEvent?.shiftKey === true, }) const { clampedX, clampedY } = clampToWall( event.node, diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index c562a0f0..30913e50 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -123,7 +123,8 @@ const DoorTool: React.FC = () => { rawLocalX: event.localPosition[0], width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, + bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassSnap: event.nativeEvent?.shiftKey === true, }) const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) @@ -176,7 +177,8 @@ const DoorTool: React.FC = () => { rawLocalX: event.localPosition[0], width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, + bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassSnap: event.nativeEvent?.shiftKey === true, }) const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) @@ -268,7 +270,8 @@ const DoorTool: React.FC = () => { rawLocalX: event.localPosition[0], width: draftRef.current.width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, + bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassSnap: event.nativeEvent?.shiftKey === true, }) const { clampedX, clampedY } = clampToWall( event.node, diff --git a/packages/nodes/src/dormer/__tests__/geometry.test.ts b/packages/nodes/src/dormer/__tests__/geometry.test.ts index 8094e05c..8774d200 100644 --- a/packages/nodes/src/dormer/__tests__/geometry.test.ts +++ b/packages/nodes/src/dormer/__tests__/geometry.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { getRoofSegmentSurfaceY, type RoofSegmentNode } from '@pascal-app/core' +import { getDormerExposedFaces } from '../csg-geometry' import { buildDormerGhostGeometry, dormerSupportsArch, @@ -41,3 +43,74 @@ describe('windowShape predicates', () => { expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'rectangle' }))).toBe(false) }) }) + +const hostSegment = (overrides?: Partial): RoofSegmentNode => + ({ + object: 'node', + id: 'rseg_fixture', + type: 'roof-segment', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 40, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + ...overrides, + }) as RoofSegmentNode + +// Default-dims dormer resting on the host surface at (x, z) — mirrors +// `useDormerPlacement`, which anchors dormer-local Y=0 at the cursor's +// surface height. +const dormerAt = (segment: RoofSegmentNode, x: number, z: number, rotation = 0) => + DormerNode.parse({ position: [x, getRoofSegmentSurfaceY(segment, x, z), z], rotation }) + +describe('getDormerExposedFaces', () => { + test('default dormer mid-slope on the default 40° gable shows the down-slope window', () => { + const seg = hostSegment() + expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5), seg)).toEqual({ front: true, back: false }) + }) + + test('35° gable mid-slope stays exposed (centre datum, not window bottom)', () => { + const seg = hostSegment({ pitch: 35 }) + expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5), seg).front).toBe(true) + }) + + test('eave band: face hanging past the structural eave keeps the window (no plateau)', () => { + const seg = hostSegment() + expect(getDormerExposedFaces(dormerAt(seg, 0, 2.8), seg).front).toBe(true) + }) + + test('on the −Z slope the back face is the exposed one', () => { + const seg = hostSegment() + expect(getDormerExposedFaces(dormerAt(seg, 0, -1.5), seg)).toEqual({ front: false, back: true }) + }) + + test('hip end-slope: face X feeds the max(fx, fz) profile', () => { + const seg = hostSegment({ roofType: 'hip' }) + expect(getDormerExposedFaces(dormerAt(seg, 2.5, 0, Math.PI / 2), seg)).toEqual({ + front: true, + back: false, + }) + }) + + test('~10° pitch buries the window on both faces', () => { + const seg = hostSegment({ pitch: 10 }) + expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5), seg)).toEqual({ front: false, back: false }) + }) + + test('a π yaw swaps which face is down-slope', () => { + const seg = hostSegment() + expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5, Math.PI), seg)).toEqual({ + front: false, + back: true, + }) + }) +}) diff --git a/packages/nodes/src/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index 27ebc162..a392d67a 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -1,7 +1,7 @@ import { type DormerNode, - getActiveRoofHeight, getPitchFromActiveRoofHeight, + getRoofSegmentSurfaceY, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, } from '@pascal-app/core' @@ -191,73 +191,61 @@ function createDormerWindowCutGeometry( return new THREE.BoxGeometry(w, h, depth) } +// Exposure datum: a face shows its window when the window CENTER clears +// the host's structural surface line (≥ half the window visible). +// Gating on the window BOTTOM suppressed the default window on the +// default 40° roof (break-even ≈ 36.7° pitch) and across the whole +// lower-slope/overhang band. A partially buried window reads as a +// window meeting the roof line: the host shingle shell occludes the +// buried frame from outside (the dormer roof cut only clears the inner +// cavity, 5cm short of the gable face), and the glass panes span the +// full opening so the wall cut never reads as a see-through hole. The +// margin only absorbs float noise at the grazing boundary — suppress +// only when the window is truly unplaceable. +const WINDOW_CENTER_MIN_CLEARANCE = 0.01 + /** - * Which gable faces of a dormer have a *fully visible window opening* - * (not clipped by the host roof slope). "front" = mesh-local +Z, - * "back" = mesh-local −Z (after the +π/2 yaw bake for non-shed roofs). + * Which gable faces of a dormer have a visible window opening. + * "front" = mesh-local +Z, "back" = mesh-local −Z (after the +π/2 yaw + * bake for non-shed roofs). * - * The criterion is window-bottom-above-slope, not wall-top-above-slope: - * the dormer wall extends well below the window into the skirt that's - * buried inside the roof, so checking just "does any wall poke above - * the slope" is far too lenient — a dormer whose eave barely clears - * the roof would pass even though the entire window (which sits inside - * the skirt, well below the eave) is buried. Switching to the window - * bottom collapses both the CSG window-cut decision (which calls into - * this function in `generateDormerGeometry`) and the live render gate - * (window-assembly.tsx) onto the right line: the window only renders - * where it's actually visible from outside. + * Each face centre is lifted into segment-local X *and* Z (the yaw + * matters, and on hip hosts the end slopes fall along X) and compared + * against the host's canonical per-type surface line via + * `getRoofSegmentSurfaceY`, which extrapolates past the structural + * eave instead of plateauing at the wall top — a face hanging in free + * air past the eave keeps dropping. Gates both the CSG window-cut + * decision (`generateDormerGeometry`) and the live render + * (window-assembly.tsx). */ export function getDormerExposedFaces( dormer: DormerNode, hostSegment: RoofSegmentNode, ): { front: boolean; back: boolean } { const halfDepth = dormer.depth / 2 - const dormerZ = dormer.position[2] ?? 0 + const dormerX = dormer.position[0] ?? 0 const dormerY = dormer.position[1] ?? 0 + const dormerZ = dormer.position[2] ?? 0 const rot = dormer.rotation ?? 0 - // Gable-face centres in segment-local Z (accounts for dormer yaw). - const frontZ = dormerZ + halfDepth * Math.cos(rot) - const backZ = dormerZ - halfDepth * Math.cos(rot) + // Gable-face centres in segment-local X/Z (accounts for dormer yaw). + const faceDX = halfDepth * Math.sin(rot) + const faceDZ = halfDepth * Math.cos(rot) - // Window bottom in dormer-local Y. Mirrors `getDormerSkirtWindowDims` - // so both functions read the same window position. The window sits - // in the skirt below the eave (dormer-local Y=0), so `centerY` is - // typically negative; subtracting half the window height lands us at - // the bottom edge. + // Window centre in segment-local Y. Mirrors `getDormerSkirtWindowDims` + // so both functions read the same window position: dormer-local Y=0 + // sits at `dormer.position[1]` and the window centre sits in the + // skirt at -(skirtH / 2) + windowOffsetY. const skirtH = dormerSkirtHeight(dormer) - const winH = Math.max(0, dormer.windowHeight ?? 0) - const winOffsetY = dormer.windowOffsetY ?? 0 - const windowCenterDormerY = -(skirtH / 2) + winOffsetY - const windowBottomDormerY = windowCenterDormerY - winH / 2 - // Lift into segment-local Y: dormer-local Y=0 sits at `dormer.position[1]`. - const windowBottomSegY = dormerY + windowBottomDormerY + const windowCenterSegY = dormerY - skirtH / 2 + (dormer.windowOffsetY ?? 0) - const hostWh = hostSegment.wallHeight ?? 0.5 - const hostRh = getActiveRoofHeight(hostSegment) - const hostDepth = hostSegment.depth ?? 4 + const clears = (faceX: number, faceZ: number): boolean => + windowCenterSegY - getRoofSegmentSurfaceY(hostSegment, faceX, faceZ) > + WINDOW_CENTER_MIN_CLEARANCE - const roofHeightAtZ = (segZ: number): number => { - const hostType = hostSegment.roofType ?? 'gable' - if (hostType === 'flat') return hostWh - if (hostType === 'shed') { - const t = Math.max(0, Math.min(1, (segZ + hostDepth / 2) / Math.max(hostDepth, 0.01))) - return hostWh + hostRh * (1 - t) - } - const halfD = Math.max(hostDepth / 2, 0.01) - const t = Math.max(0, Math.min(1, Math.abs(segZ) / halfD)) - return hostWh + hostRh * (1 - t) - } - - // A face is "exposed" only if the *window bottom* clears the host - // slope at that face's Z by a meaningful amount — borderline cases - // (slope grazing the window bottom) suppress the window so we don't - // render a partially-clipped frame poking out of the roof. 5cm - // matches the threshold the prior wall-top check used. - const minPokeOut = 0.05 return { - front: windowBottomSegY - roofHeightAtZ(frontZ) > minPokeOut, - back: windowBottomSegY - roofHeightAtZ(backZ) > minPokeOut, + front: clears(dormerX + faceDX, dormerZ + faceDZ), + back: clears(dormerX - faceDX, dormerZ - faceDZ), } } @@ -352,12 +340,15 @@ export function generateDormerGeometry( dormerBrushes.innerBrush, SUBTRACTION, ) as Brush + prepareBrushForCSG(hollowWall) const shinDeck = csgEvaluator.evaluate( dormerBrushes.shinSlab, dormerBrushes.deckSlab, ADDITION, ) as Brush + prepareBrushForCSG(shinDeck) dormerSolid = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) as Brush + prepareBrushForCSG(dormerSolid) hollowWall.geometry.dispose() shinDeck.geometry.dispose() @@ -376,7 +367,9 @@ export function generateDormerGeometry( hostBrushes.deckSlab, ADDITION, ) as Brush + prepareBrushForCSG(wallPlusDeck) hostSolid = csgEvaluator.evaluate(wallPlusDeck, hostBrushes.shinSlab, ADDITION) as Brush + prepareBrushForCSG(hostSolid) wallPlusDeck.geometry.dispose() hostBrushes.deckSlab.geometry.dispose() hostBrushes.shinSlab.geometry.dispose() @@ -393,8 +386,9 @@ export function generateDormerGeometry( groundBoxGeo.addGroup(0, indexCount, 0) computeGeometryBoundsTree(groundBoxGeo) const groundBrush = new Brush(groundBoxGeo, roofCsgDummyMats[0]) - groundBrush.updateMatrixWorld() + prepareBrushForCSG(groundBrush) const fullTrim = csgEvaluator.evaluate(hostSolid, groundBrush, ADDITION) as Brush + prepareBrushForCSG(fullTrim) hostSolid.geometry.dispose() groundBrush.geometry.dispose() hostSolid = fullTrim @@ -416,6 +410,7 @@ export function generateDormerGeometry( prepareBrushForCSG(hostSolid) const trimmed = csgEvaluator.evaluate(dormerSolid, hostSolid, SUBTRACTION) as Brush + prepareBrushForCSG(trimmed) dormerSolid.geometry.dispose() hostSolid.geometry.dispose() hostSolid = null @@ -447,8 +442,9 @@ export function generateDormerGeometry( cutGeo.addGroup(0, idxCount, 0) computeGeometryBoundsTree(cutGeo) const brush = new Brush(cutGeo, roofCsgDummyMats[0]) - brush.updateMatrixWorld() + prepareBrushForCSG(brush) const result = csgEvaluator.evaluate(dormerSolid!, brush, SUBTRACTION) as Brush + prepareBrushForCSG(result) dormerSolid!.geometry.dispose() brush.geometry.dispose() dormerSolid = result @@ -557,7 +553,7 @@ export function buildDormerCutShape( // ends up along mesh-(-Z) and the extrusion ends up along mesh-X. // // `getRoofSegmentBrushes`'s shed slope puts the peak at z=-d/2 - // and the eave at z=+d/2 (matching the `roofHeightAtZ` helper). + // and the eave at z=+d/2 (matching `getRoofSegmentSurfaceY`). // After the +π/2 rotation, shape-X=+hd → mesh-Z=-hd, so place the // PEAK at shape-X=+hd and the EAVE at shape-X=-hd to keep the cut // aligned with the dormer body's actual slope direction. diff --git a/packages/nodes/src/dormer/use-dormer-placement.ts b/packages/nodes/src/dormer/use-dormer-placement.ts index 1d88fb43..5a628cb5 100644 --- a/packages/nodes/src/dormer/use-dormer-placement.ts +++ b/packages/nodes/src/dormer/use-dormer-placement.ts @@ -118,7 +118,7 @@ export function useDormerPlacement(opts: { const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M const sz = Math.round(wz / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } diff --git a/packages/nodes/src/dormer/window-assembly.tsx b/packages/nodes/src/dormer/window-assembly.tsx index 12ca12a6..4c7734cb 100644 --- a/packages/nodes/src/dormer/window-assembly.tsx +++ b/packages/nodes/src/dormer/window-assembly.tsx @@ -118,12 +118,10 @@ const DormerWindowAssembly = ({ // non-zero yaw needs to recompute exposure to know which gable // is now poking above the slope. node.rotation, - // Window position + height feed `getDormerExposedFaces` now that - // it's gating on window-bottom-above-slope (not wall-top-above- - // slope) — dragging the window down via inspector or the new - // window-height/offset handles must re-evaluate which gable - // still has a fully-visible opening. - node.windowHeight, + // The window's vertical placement feeds `getDormerExposedFaces` + // (gates on the window CENTER clearing the host slope) — dragging + // the window down via inspector or the offset handle must + // re-evaluate which gable still exposes the opening. node.windowOffsetY, node.wallSkirtHeight, ], diff --git a/packages/nodes/src/eyebrow-vent/move-tool.tsx b/packages/nodes/src/eyebrow-vent/move-tool.tsx index 036462d1..1d5d1696 100644 --- a/packages/nodes/src/eyebrow-vent/move-tool.tsx +++ b/packages/nodes/src/eyebrow-vent/move-tool.tsx @@ -67,7 +67,10 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 - if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { + if ( + event.nativeEvent?.shiftKey !== true && + (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) + ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } diff --git a/packages/nodes/src/eyebrow-vent/tool.tsx b/packages/nodes/src/eyebrow-vent/tool.tsx index 167f0188..14e4e673 100644 --- a/packages/nodes/src/eyebrow-vent/tool.tsx +++ b/packages/nodes/src/eyebrow-vent/tool.tsx @@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' import { eyebrowVentDefinition } from './definition' import EyebrowVentPreview from './preview' @@ -33,6 +33,7 @@ const EyebrowVentTool = () => { const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) const [previewYaw, setPreviewYaw] = useState(0) + const [previewRotation, setPreviewRotation] = useState(0) const lastSnapRef = useRef<[number, number] | null>(null) const previewNode = useMemo( @@ -41,9 +42,9 @@ const EyebrowVentTool = () => { ...eyebrowVentDefinition.defaults(), name: 'Eyebrow Vent', position: [0, 0, 0], - rotation: 0, + rotation: previewRotation, }), - [], + [previewRotation], ) useEffect(() => { @@ -65,7 +66,7 @@ const EyebrowVentTool = () => { const sx = Math.round(wx * 20) / 20 const sz = Math.round(wz * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } @@ -76,6 +77,7 @@ const EyebrowVentTool = () => { const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) + setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) event.stopPropagation() } @@ -95,7 +97,7 @@ const EyebrowVentTool = () => { name: 'Eyebrow Vent', roofSegmentId: hit.segment.id, position: [hit.localX, hit.localY, hit.localZ], - rotation: 0, + rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), }) state.createNode(vent, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index d1a6d516..f53a3651 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -14,7 +14,6 @@ import { isSegmentLongEnough, snapFenceDraftPoint, useAlignmentGuides, - WALL_FINE_GRID_STEP, } from '@pascal-app/editor' /** @@ -165,14 +164,13 @@ export const moveFenceEndpointDragAction: DragAction { const planPoint: FencePlanPoint = [point[0], point[1]] // Endpoint move = grid snap only; the 45°-from-start angle snap - // is draft-only. Shift switches to the fine grid step for - // precision, mirroring the wall convention. + // is draft-only. Shift is a hard snap bypass. const snapped = snapFenceDraftPoint({ point: planPoint, walls: ctx.levelWalls, fences: ctx.levelFences, ignoreFenceIds: [ctx.fenceId as string], - step: modifiers.shift ? WALL_FINE_GRID_STEP : undefined, + bypassSnap: modifiers.shift, }) // Figma-style alignment: nudge the dragged endpoint onto another wall / @@ -180,7 +178,7 @@ export const moveFenceEndpointDragAction: DragAction 0) { + if (!modifiers.shift && ctx.alignCandidates.length > 0) { const ar = resolveAlignment({ moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }], candidates: ctx.alignCandidates, @@ -190,6 +188,8 @@ export const moveFenceEndpointDragAction: DragAction = ({ node }) => { } const onGridMove = (event: GridEvent) => { + const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true const snapStep = getSegmentGridStep() - const localX = shiftPressedRef.current + const localX = bypassSnap ? event.localPosition[0] : snapScalarToGrid(event.localPosition[0], snapStep) - const localZ = shiftPressedRef.current + const localZ = bypassSnap ? event.localPosition[2] : snapScalarToGrid(event.localPosition[2], snapStep) @@ -101,7 +102,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { (localX - chord.midpoint.x) * chord.normal.x + (localZ - chord.midpoint.y) * chord.normal.y ) - const snappedOffset = shiftPressedRef.current + const snappedOffset = bypassSnap ? offsetFromMidpoint : snapScalarToGrid(offsetFromMidpoint, snapStep) const nextCurveOffset = normalizeWallCurveOffset( @@ -110,6 +111,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { ) if ( + !bypassSnap && previousCurveOffsetRef.current !== null && nextCurveOffset !== previousCurveOffsetRef.current ) { diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index 36fe97bc..b01da5e2 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -223,7 +223,7 @@ export const fenceDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Set fence start / end' }, - { key: 'Shift', label: 'Allow non-45° angles' }, + { key: 'Shift', label: 'Free angle (no 15° snap)' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index 2843e17a..6236a419 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -20,7 +20,6 @@ import { snapFenceDraftPoint, snapScalarToGrid, useAlignmentGuides, - WALL_FINE_GRID_STEP, WALL_GRID_STEP, } from '@pascal-app/editor' @@ -159,16 +158,15 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance = { const sceneNodes = useScene.getState().nodes const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId) // Endpoint move = grid snap only; the 45°-from-start angle - // snap is draft-only. Shift switches to the fine grid step for - // precision, matching the 3D fence endpoint action. - const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP + // snap is draft-only. Shift bypasses grid, magnetic, and alignment snap. const snapped = snapFenceDraftPoint({ point: planPoint as FencePlanPoint, walls: nextWalls, fences: nextFences, ignoreFenceIds: [node.id], - step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined, - gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep) as FencePlanPoint, + bypassSnap: modifiers.shiftKey, + magnetic: !modifiers.shiftKey, + gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint, }) // Figma-style alignment on the dragged endpoint — snaps it onto // another object's edge / wall face and publishes a guide, matching @@ -176,6 +174,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance = { // siblings (which cascade with the endpoint) are excluded from the // candidate pool. Alt is reserved for detach here, NOT bypass. const aligned = alignFloorplanDraftPoint(snapped, { + bypass: modifiers.shiftKey, excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)], }) as FencePlanPoint const nextStart = endpoint === 'start' ? aligned : fixedPoint diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index c5bc0a6e..c57be884 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -1,6 +1,13 @@ 'use client' -import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core' +import { + emitter, + type FenceNode, + type GridEvent, + getWallCurveLength, + useScene, + type WallNode, +} from '@pascal-app/core' import { CursorSphere, type FencePlanPoint, @@ -121,13 +128,43 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const movingPoint = endpoint === 'start' ? liveStart : liveEnd // Ticker SFX on each grid-snap step, mirroring the wall endpoint tool. - // The action snaps the point before writing to the scene, so `movingPoint` - // only changes in discrete grid steps — the right cadence for the click. - // First tick just seeds the ref (no sound on mount). + // First tick just seeds the ref (no sound on mount). The drag action receives + // the Shift modifier through grid events, so mirror that modifier here to + // avoid playing grid ticks while snap is bypassed. const previousGridPosRef = useRef(null) + const shiftPressedRef = useRef(false) + useEffect(() => { + const onGridMove = (event: GridEvent) => { + shiftPressedRef.current = event.nativeEvent?.shiftKey === true + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Shift') shiftPressedRef.current = true + } + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') shiftPressedRef.current = false + } + const onBlur = () => { + shiftPressedRef.current = false + } + emitter.on('grid:move', onGridMove) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) + return () => { + emitter.off('grid:move', onGridMove) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) + } + }, []) + useEffect(() => { const prev = previousGridPosRef.current - if (prev && (prev[0] !== movingPoint[0] || prev[1] !== movingPoint[1])) { + if ( + !shiftPressedRef.current && + prev && + (prev[0] !== movingPoint[0] || prev[1] !== movingPoint[1]) + ) { triggerSFX('sfx:grid-snap') } previousGridPosRef.current = movingPoint diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index 2831c2dc..5ca806aa 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -193,14 +193,17 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { + const bypassSnap = event.nativeEvent?.shiftKey === true const [localX, localZ] = snapFenceDraftPoint({ point: [event.localPosition[0], event.localPosition[2]], walls: levelWalls, fences: levelFences, ignoreFenceIds: [fenceId], + bypassSnap, }) if ( + !bypassSnap && previousGridPosRef.current && (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 0101344c..17de5cd1 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -30,7 +30,7 @@ import { triggerSFX, useAlignmentGuides, useEditor, - WALL_FINE_GRID_STEP, + useSegmentDraftChain, } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -485,6 +485,7 @@ export const FenceTool: React.FC = () => { buildingState.current = 0 previewRef.current.visible = false setDraftMeasurement(null) + useSegmentDraftChain.getState().clear('fence') useAlignmentGuides.getState().clear() } @@ -492,20 +493,29 @@ export const FenceTool: React.FC = () => { if (!(cursorRef.current && previewRef.current)) return const { walls, fences } = getCurrentLevelElements() const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] - // Default = active grid step; Shift switches to the fine step - // (0.05m). No 45° angle snap — see `wall/tool.tsx` for rationale. - const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined - const bypassAlign = event.nativeEvent?.altKey === true + // While drafting, the segment locks to 15° rays from its start + // unless Shift is held. Shift also bypasses grid and magnetic snap. + const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true + const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap if (buildingState.current === 1) { + const angleLocked = !bypassSnap const snappedLocal = alignPoint( - snapFenceDraftPoint({ point: localPoint, walls, fences, step }), - bypassAlign, + snapFenceDraftPoint({ + point: localPoint, + walls, + fences, + start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, + angleSnap: angleLocked, + bypassSnap, + }), + bypassAlign || angleLocked, ) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) cursorRef.current.position.copy(endingPoint.current) const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] if ( + !bypassSnap && previousFenceEnd && (currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1]) ) { @@ -532,7 +542,7 @@ export const FenceTool: React.FC = () => { ) } else { const snappedPoint = alignPoint( - snapFenceDraftPoint({ point: localPoint, walls, fences, step }), + snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }), bypassAlign, ) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) @@ -548,12 +558,12 @@ export const FenceTool: React.FC = () => { const { walls, fences } = getCurrentLevelElements() const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] - const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined - const bypassAlign = event.nativeEvent?.altKey === true + const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true + const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap if (buildingState.current === 0) { const snappedStart = alignPoint( - snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }), + snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }), bypassAlign, ) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) @@ -563,9 +573,17 @@ export const FenceTool: React.FC = () => { previewRef.current.visible = true setDraftMeasurement(null) } else { + const angleLocked = !bypassSnap const snappedEnd = alignPoint( - snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }), - bypassAlign, + snapFenceDraftPoint({ + point: localClick, + walls, + fences, + start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, + angleSnap: angleLocked, + bypassSnap, + }), + bypassAlign || angleLocked, ) const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z @@ -582,6 +600,10 @@ export const FenceTool: React.FC = () => { useAlignmentGuides.getState().clear() const nextStart = createdFence.end + // Publish the resolved chain start so the 2D floor-plan draft + // chains its next segment from the same point (its own snap + // pipeline can resolve a slightly different endpoint). + useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]]) startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) endingPoint.current.copy(startingPoint.current) cursorRef.current?.position.copy(startingPoint.current) @@ -599,6 +621,12 @@ export const FenceTool: React.FC = () => { if (e.key === 'Shift') shiftPressed.current = false } + // Cmd-tabbing away mid-draft never delivers the keyup — reset so the + // angle lock isn't stuck off when focus returns. + const onBlur = () => { + shiftPressed.current = false + } + const onCancel = () => { if (buildingState.current === 1) { markToolCancelConsumed() @@ -611,6 +639,7 @@ export const FenceTool: React.FC = () => { emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) return () => { emitter.off('grid:move', onGridMove) @@ -618,6 +647,8 @@ export const FenceTool: React.FC = () => { emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) + useSegmentDraftChain.getState().clear('fence') useAlignmentGuides.getState().clear() } }, [unit]) diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index f030cbbc..08ce9ab3 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -225,6 +225,7 @@ export function buildGutterGeometry( const drillBrush = new Brush(drill) prepareBrushForCSG(drillBrush) const next = csgEvaluator.evaluate(workingBrush, drillBrush, SUBTRACTION) as Brush + prepareBrushForCSG(next) // Free the previous step's intermediate result (but not `merged`, // which is disposed once below). if (workingBrush.geometry !== merged) workingBrush.geometry.dispose() diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx index 57295ba0..32780993 100644 --- a/packages/nodes/src/gutter/move-tool.tsx +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -97,7 +97,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { const sx = Math.round(snap.eaveX * 20) / 20 const sz = Math.round(snap.eaveZ * 20) / 20 - if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { + if ( + event.nativeEvent?.shiftKey !== true && + (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) + ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index f2066e09..1258519c 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -83,7 +83,7 @@ const GutterTool = () => { const sx = Math.round(snap.eaveX * 20) / 20 const sz = Math.round(snap.eaveZ * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 2f9dc6fe..9224d701 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -22,10 +22,10 @@ const ROTATE_RING_OFFSET = 0.06 // Whole-item rotation handle — the two-headed curved arrow. `arc-resize` // does the angular drag math (raycasts a horizontal plane at the gizmo's // Y, measures cursor bearing around the item's local origin, returns the -// delta). Holding Shift snaps to 15° increments (handled generically in -// node-arrow-handles for any `shape: 'rotate'`), matching the R/T rotate -// step for placed items. Item rotation is stored as `[x, y, z]`; only the -// Y component turns. +// delta). Rotation snaps to 15° increments by default; holding Shift +// bypasses that snap (handled generically in node-arrow-handles for any +// `shape: 'rotate'`), matching the R/T rotate step for placed items. Item +// rotation is stored as `[x, y, z]`; only the Y component turns. function itemRotateHandle(): HandleDescriptor { return { kind: 'arc-resize', diff --git a/packages/nodes/src/item/floorplan-move.ts b/packages/nodes/src/item/floorplan-move.ts index d8747045..49f14faf 100644 --- a/packages/nodes/src/item/floorplan-move.ts +++ b/packages/nodes/src/item/floorplan-move.ts @@ -211,16 +211,17 @@ function buildWallItemSession( // Figma-style along-wall alignment (edge-to-edge with other openings / // wall items / wall ends), winning over the 0.5m grid snap; falls back - // to grid when nothing aligns. Alt bypasses; Shift drops the grid snap. - const neighborX = modifiers.altKey - ? null - : snapLocalXToNeighbors({ - wall: hit.wall, - localX: hit.localX, - width, - selfId: node.id as AnyNodeId, - nodes, - }) + // to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap. + const neighborX = + modifiers.altKey || modifiers.shiftKey + ? null + : snapLocalXToNeighbors({ + wall: hit.wall, + localX: hit.localX, + width, + selfId: node.id as AnyNodeId, + nodes, + }) const step = useEditor.getState().gridSnapStep const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : Math.round(hit.localX / step) * step) @@ -286,7 +287,7 @@ function buildFloorItemSession( rotationY, ), candidates, - { bypass: modifiers.altKey }, + { bypass: modifiers.altKey || modifiers.shiftKey }, ) const sourceY = node.position[1] diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index e72fa2df..62752d6f 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -80,7 +80,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 - if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { + if ( + event.nativeEvent?.shiftKey !== true && + (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) + ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } diff --git a/packages/nodes/src/ridge-vent/tool.tsx b/packages/nodes/src/ridge-vent/tool.tsx index 2ef682fc..9a1a08eb 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -88,7 +88,7 @@ const RidgeVentTool = () => { const sx = Math.round(ridgeWorld[0] * 20) / 20 const sz = Math.round(ridgeWorld[2] * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.ts b/packages/nodes/src/roof-segment/floorplan-affordances.ts index aa50683a..32212306 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -79,11 +79,12 @@ export const roofSegmentResizeAffordance: FloorplanAffordance = return { affectedIds: [segmentId], - apply({ planPoint }) { + apply({ planPoint, modifiers }) { const currentLocal = projectLocalAxis(planPoint[0], planPoint[1]) const delta = (currentLocal - initialLocal) * side const rawValue = initialValue + 2 * delta - const snappedValue = gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue + const snappedValue = + !modifiers.shiftKey && gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue const newValue = Math.max(MIN_ROOF_DIM, snappedValue) lastValue = newValue useScene diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts index fa672f7b..5c2abe3d 100644 --- a/packages/nodes/src/shared/floor-placement.ts +++ b/packages/nodes/src/shared/floor-placement.ts @@ -36,6 +36,7 @@ type FloorPlacementAlignmentArgs = { gridStep: number candidates: Parameters[0]['candidates'] bypassAlignment?: boolean + bypassGrid?: boolean rotationY?: number } @@ -45,18 +46,23 @@ export function getLevelLocalSnappedPosition( levelId: string, event: FloorPlacementClickTriggerEvent, gridStep: number, + bypassGrid = false, ): [number, number, number] { const levelObject = sceneRegistry.nodes.get(levelId) if (!levelObject) { const rawPoint = 'node' in event ? event.position : event.localPosition - const [sx, sz] = snapPointToGrid([rawPoint[0], rawPoint[2]], gridStep) + const [sx, sz] = bypassGrid + ? [rawPoint[0], rawPoint[2]] + : snapPointToGrid([rawPoint[0], rawPoint[2]], gridStep) return [sx, 0, sz] } worldVector.set(event.position[0], event.position[1], event.position[2]) levelObject.updateWorldMatrix(true, false) levelObject.worldToLocal(worldVector) - const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], gridStep) + const [sx, sz] = bypassGrid + ? [worldVector.x, worldVector.z] + : snapPointToGrid([worldVector.x, worldVector.z], gridStep) return [sx, 0, sz] } @@ -67,9 +73,10 @@ export function resolveAlignedFloorPlacement({ gridStep, candidates, bypassAlignment = false, + bypassGrid = false, rotationY = 0, }: FloorPlacementAlignmentArgs) { - const [sx, sz] = snapPointToGrid([rawX, rawZ], gridStep) + const [sx, sz] = bypassGrid ? [rawX, rawZ] : snapPointToGrid([rawX, rawZ], gridStep) let ax = sx let az = sz diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 3c95163f..d3c762d6 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -293,6 +293,7 @@ export const MoveRoofTool: React.FC<{ point: [event.localPosition[0], event.localPosition[2]], walls: levelWalls, fences: levelFences, + bypassSnap: event.nativeEvent?.shiftKey === true, }) const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y) const [rawLocalX, rawLocalZ] = computeLocal( @@ -312,12 +313,17 @@ export const MoveRoofTool: React.FC<{ let [localX, localZ] = resolved.point if (alignTopLevel) { - const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true) + const aligned = alignLocalPoint( + localX, + localZ, + event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + ) localX = aligned[0] localZ = aligned[1] } if ( + event.nativeEvent?.shiftKey !== true && previousGridPosRef.current && (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { diff --git a/packages/nodes/src/shared/placeholder-geometry.ts b/packages/nodes/src/shared/placeholder-geometry.ts index 52ce511c..c3c2cb34 100644 --- a/packages/nodes/src/shared/placeholder-geometry.ts +++ b/packages/nodes/src/shared/placeholder-geometry.ts @@ -24,6 +24,7 @@ export function createPlaceholderGeometry(groupCount = 0): BufferGeometry { geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3)) geometry.setAttribute('normal', new Float32BufferAttribute(new Float32Array(9), 3)) geometry.setAttribute('uv', new Float32BufferAttribute(new Float32Array(6), 2)) + geometry.setAttribute('uv2', new Float32BufferAttribute(new Float32Array(6), 2)) for (let group = 0; group < groupCount; group++) { geometry.addGroup(0, 0, group) } diff --git a/packages/nodes/src/shared/polygon-centroid-move.ts b/packages/nodes/src/shared/polygon-centroid-move.ts index 9684ea05..c6dd78e0 100644 --- a/packages/nodes/src/shared/polygon-centroid-move.ts +++ b/packages/nodes/src/shared/polygon-centroid-move.ts @@ -104,7 +104,7 @@ export function createPolygonCentroidMoveTarget(args: { let dx = target[0] - originalCenter[0] let dz = target[1] - originalCenter[1] - if (!modifiers.altKey && candidates.length > 0) { + if (!(modifiers.altKey || modifiers.shiftKey) && candidates.length > 0) { const result = resolveAlignment({ moving: polygonAnchors(id, translatePolygon(originalPolygon, dx, dz)), candidates, diff --git a/packages/nodes/src/shared/roof-surface.test.ts b/packages/nodes/src/shared/roof-surface.test.ts new file mode 100644 index 00000000..1b5157df --- /dev/null +++ b/packages/nodes/src/shared/roof-surface.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test' +import type { RoofSegmentNode } from '@pascal-app/core' +import { getDownSlopeYaw } from './roof-surface' + +const fixtureSegment = (overrides?: Partial): RoofSegmentNode => + ({ + object: 'node', + id: 'rseg_fixture', + type: 'roof-segment', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 2.5, + pitch: (Math.atan2(2, 3) * 180) / Math.PI, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + ...overrides, + }) as RoofSegmentNode + +describe('getDownSlopeYaw', () => { + test('gable +z face: local +z already points down-slope (yaw 0)', () => { + expect(getDownSlopeYaw(0, 1, fixtureSegment())).toBeCloseTo(0) + }) + test('gable −z face: half-turn so +z faces the −z eave (yaw π)', () => { + expect(getDownSlopeYaw(0, -1, fixtureSegment())).toBeCloseTo(Math.PI) + }) + test('hip +x face yaws +π/2', () => { + expect(getDownSlopeYaw(2, 0, fixtureSegment({ roofType: 'hip' }))).toBeCloseTo(Math.PI / 2) + }) + test('hip −x face yaws −π/2', () => { + expect(getDownSlopeYaw(-2, 0, fixtureSegment({ roofType: 'hip' }))).toBeCloseTo(-Math.PI / 2) + }) + test('flat segment has no down-slope direction (yaw 0)', () => { + expect(getDownSlopeYaw(0, 0, fixtureSegment({ roofType: 'flat' }))).toBe(0) + }) +}) diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts index 9a1368b3..f60278cd 100644 --- a/packages/nodes/src/shared/roof-surface.ts +++ b/packages/nodes/src/shared/roof-surface.ts @@ -137,3 +137,15 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni const m = new THREE.Matrix4().makeBasis(right, normal, forward) return out.setFromRotationMatrix(m) } + +// Yaw (about the surface normal, composed AFTER `surfaceQuatFromNormal`) +// that points the node's local +Z down the slope. The analytical normals +// are axis-aligned (n.x or n.z is 0), and in the +X-projected basis above +// the down-slope direction decomposes to atan2(n.x · n.y, n.z): +Z face +// → 0, −Z → π, +X → +π/2, −X → −π/2. Kept next to `surfaceQuatFromNormal` +// so the two stay in lockstep — the formula is only valid for its basis. +export function getDownSlopeYaw(lx: number, lz: number, seg: RoofSegmentNode): number { + const n = getAnalyticalNormal(lx, lz, seg) + if (n.x === 0 && n.z === 0) return 0 + return Math.atan2(n.x * n.y, n.z) +} diff --git a/packages/nodes/src/shared/roof-wall-opening-cut.ts b/packages/nodes/src/shared/roof-wall-opening-cut.ts index 41f189bf..d8af5cb9 100644 --- a/packages/nodes/src/shared/roof-wall-opening-cut.ts +++ b/packages/nodes/src/shared/roof-wall-opening-cut.ts @@ -1,26 +1,22 @@ -import type { RoofSegmentNode, RoofWallFaceId } from '@pascal-app/core' +import type { DoorNode, RoofSegmentNode, WindowNode } from '@pascal-app/core' import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core' +import { buildOpeningCutoutGeometry, hasFlatOpeningCutoutBottom } from '@pascal-app/viewer' import * as THREE from 'three' -type RoofWallOpening = { - roofSegmentId?: string - roofFace?: RoofWallFaceId - position: [number, number, number] - width: number - height: number -} - /** * CSG cut for a door / window hosted on a roof-segment wall face - * (`capabilities.roofAccessory.buildCut`). A box through the wall + * (`capabilities.roofAccessory.buildCut`). The cut goes through the wall * mid-plane, derived from the CURRENT host geometry (the opening stores * face-local coords), so the hole follows segment resizes for free. + * Plain rectangles cut a box; shaped openings (arch / rounded / + * frameless `opening` kind) reuse the wall pipeline's cutout profile so + * roof-hosted holes match wall-hosted ones. * * Returns null for wall-hosted openings: their cut is handled by the * wall system's own cutout pipeline. */ export function buildRoofWallOpeningCut( - node: RoofWallOpening, + node: DoorNode | WindowNode, hostSegment: RoofSegmentNode, ): THREE.BufferGeometry | null { if (!node.roofSegmentId || !node.roofFace) return null @@ -32,8 +28,10 @@ export function buildRoofWallOpeningCut( // A door's cut bottom is coplanar with the wall brush base — extend it // slightly downward so three-bvh-csg never has to clip coplanar faces. + // Only a flat bottom chord may extend; a rounded bottom is never + // coplanar and shifting it would distort the profile. const bottom = node.position[1] - node.height / 2 - const bottomPad = bottom < 0.005 ? 0.02 : 0 + const bottomPad = bottom < 0.005 && hasFlatOpeningCutoutBottom(node) ? 0.02 : 0 const center = roofFacePointToSegment(hostSegment, node.roofFace, [ node.position[0], @@ -42,9 +40,35 @@ export function buildRoofWallOpeningCut( ]) const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace) - const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth) - geo.translate(0, -bottomPad / 2, 0) + const geo = buildCutGeometry(node, wallThickness, depth, bottomPad) geo.rotateY(yaw) geo.translate(center[0], center[1], center[2]) return geo } + +function buildCutGeometry( + node: DoorNode | WindowNode, + wallThickness: number, + depth: number, + bottomPad: number, +): THREE.BufferGeometry { + const shaped = + node.openingKind === 'opening' || + node.openingShape === 'arch' || + node.openingShape === 'rounded' + + if (!shaped) { + const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth) + geo.translate(0, -bottomPad / 2, 0) + return geo + } + + const halfWidth = node.width / 2 + const halfHeight = node.height / 2 + return buildOpeningCutoutGeometry( + node, + { left: -halfWidth, right: halfWidth, bottom: -halfHeight - bottomPad, top: halfHeight }, + depth, + wallThickness, + ) +} diff --git a/packages/nodes/src/shared/wall-opening-alignment.ts b/packages/nodes/src/shared/wall-opening-alignment.ts index 43af9e46..48f8cc30 100644 --- a/packages/nodes/src/shared/wall-opening-alignment.ts +++ b/packages/nodes/src/shared/wall-opening-alignment.ts @@ -21,7 +21,8 @@ const MIN_AXIS_COMPONENT = 0.5 * runs along and map it to the along-wall coordinate that lands the opening on * it. Falls back to the half-metre snap when nothing aligns, and clears the * guide on bypass / no-match. Returns the localX to use (X-clamped to the wall - * given `width`). `bypass` (Alt) disables alignment. + * given `width`). `bypass` disables alignment; `bypassSnap` also skips the + * half-metre fallback. */ export function resolveWallSlideAlignment(args: { wallNode: WallNode @@ -29,9 +30,10 @@ export function resolveWallSlideAlignment(args: { width: number candidates: readonly AlignmentAnchor[] bypass: boolean + bypassSnap?: boolean }): number { - const { wallNode, rawLocalX, width, candidates, bypass } = args - const base = snapToHalf(rawLocalX) + const { wallNode, rawLocalX, width, candidates, bypass, bypassSnap = false } = args + const base = bypassSnap ? rawLocalX : snapToHalf(rawLocalX) if (bypass || candidates.length === 0) { useAlignmentGuides.getState().clear() return base diff --git a/packages/nodes/src/shelf/floorplan-move.ts b/packages/nodes/src/shelf/floorplan-move.ts index 9b5dbe75..c72eabfd 100644 --- a/packages/nodes/src/shelf/floorplan-move.ts +++ b/packages/nodes/src/shelf/floorplan-move.ts @@ -66,7 +66,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint // Figma-style alignment layered on the grid snap — the shelf footprint // edges snap to neighbours / wall faces and a guide is published. Alt - // bypasses (matches placement tools' "No snap"). + // bypasses alignment; Shift bypasses all snap. const { point: snapped } = applyFloorplanAlignment( gridSnapped, movingFootprintAnchors( @@ -76,7 +76,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, originalRotationY, ), candidates, - { bypass: modifiers.altKey }, + { bypass: modifiers.altKey || modifiers.shiftKey }, ) const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] lastPosition = next @@ -85,7 +85,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, // and the placement coordinators. Item / slab / wall flows fire // the same cue, so the shelf following along is the expected UX. const snapKey = `${snapped[0]},${snapped[1]}` - if (snapKey !== lastSnapKey) { + if (!modifiers.shiftKey && snapKey !== lastSnapKey) { triggerSFX('sfx:grid-snap') lastSnapKey = snapKey } diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index 4dbc866b..585de70f 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -83,7 +83,8 @@ const ShelfTool = () => { rawZ: event.localPosition[2], gridStep: useEditor.getState().gridSnapStep, candidates: alignmentCandidates, - bypassAlignment: event.nativeEvent?.altKey === true, + bypassAlignment: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassGrid: event.nativeEvent?.shiftKey === true, }) useAlignmentGuides.getState().set(guides) @@ -97,7 +98,10 @@ const ShelfTool = () => { lastCursorRef.current = position const prev = previousSnapRef.current - if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { + if ( + event.nativeEvent?.shiftKey !== true && + (!prev || prev[0] !== position[0] || prev[1] !== position[2]) + ) { triggerSFX('sfx:grid-snap') previousSnapRef.current = [position[0], position[2]] } @@ -110,7 +114,12 @@ const ShelfTool = () => { // first). Both paths apply the same grid snap. const position = lastCursorRef.current ?? - getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep) + getLevelLocalSnappedPosition( + activeLevelId, + event, + useEditor.getState().gridSnapStep, + event.nativeEvent?.shiftKey === true, + ) const shelf = ShelfNode.parse({ ...shelfDefinition.defaults(), name: 'Shelf', diff --git a/packages/nodes/src/skylight/frame-csg.ts b/packages/nodes/src/skylight/frame-csg.ts index 1dcbd819..da82d81e 100644 --- a/packages/nodes/src/skylight/frame-csg.ts +++ b/packages/nodes/src/skylight/frame-csg.ts @@ -67,14 +67,5 @@ export function buildFrameGeometry({ frameGeo.translate(0, -totalDepth / 2 + curbH, 0) - // WebGPU node renderer requests `uv2` on every geometry for lightmap support. - // CSG output only carries position + normal + uv. Copy uv → uv2 so the - // AttributeNode lookup doesn't fail and invalidate the render pipeline. - // Mirrors `ensureUv2Attribute` in packages/viewer/src/systems/roof/roof-system.tsx. - const uv = frameGeo.getAttribute('uv') - if (uv) { - frameGeo.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) - } - return frameGeo } diff --git a/packages/nodes/src/skylight/move-tool.tsx b/packages/nodes/src/skylight/move-tool.tsx index b93bfc4c..2255ec08 100644 --- a/packages/nodes/src/skylight/move-tool.tsx +++ b/packages/nodes/src/skylight/move-tool.tsx @@ -98,7 +98,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { const onRoofMove = (event: RoofEvent) => { const sx = Math.round(event.position[0] * 20) / 20 const sz = Math.round(event.position[2] * 20) / 20 - if (sx !== lastSnapX || sz !== lastSnapZ) { + if (event.nativeEvent?.shiftKey !== true && (sx !== lastSnapX || sz !== lastSnapZ)) { triggerSFX('sfx:grid-snap') lastSnapX = sx lastSnapZ = sz diff --git a/packages/nodes/src/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index 47bc6dd5..4111cd77 100644 --- a/packages/nodes/src/skylight/renderer.tsx +++ b/packages/nodes/src/skylight/renderer.tsx @@ -628,8 +628,7 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { const glassMaterial = useMemo(() => { // Untextured glass (and textures-off mode) takes the themed 'glazing' - // role material — already DoubleSide + semi-transparent, and shared - // from the cache, so it must not be mutated. + // role material from the shared cache, so it must not be mutated. if (!textures || (!node.glassMaterial && !node.glassMaterialPreset)) { return createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme) } @@ -638,7 +637,6 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { : (createMaterialFromPresetRef(node.glassMaterialPreset, shading) ?? defaultGlassMaterial.clone()) if (mat && typeof mat === 'object') { - ;(mat as THREE.Material).side = THREE.DoubleSide if (mat instanceof THREE.MeshPhysicalMaterial) { mat.thickness = glassThickness } diff --git a/packages/nodes/src/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index cbaadcb4..ffc442c8 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -59,7 +59,7 @@ const SkylightTool = () => { const sx = Math.round(wx * 20) / 20 const sz = Math.round(wz * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } diff --git a/packages/nodes/src/slab/boundary-editor.tsx b/packages/nodes/src/slab/boundary-editor.tsx index dde74c82..43726dd3 100644 --- a/packages/nodes/src/slab/boundary-editor.tsx +++ b/packages/nodes/src/slab/boundary-editor.tsx @@ -73,6 +73,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI levelId: slabLevelId, excludeId: slabId, altKey: context.nativeEvent?.altKey === true, + shiftKey: context.nativeEvent?.shiftKey === true, }).point, [slabId, slabLevelId], ) diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 01d0b971..ecb2ff65 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -166,7 +166,7 @@ export const slabDefinition: NodeDefinition = { handles: slabHandles, // Stage D: kind-owned placement tool. Multi-click polygon drawing - // with axis/45° snap (Shift to defeat). + // with 15° angle snap (Shift to defeat). tool: () => import('./tool'), // Stage D — all four slab drag-affordances live in this folder. diff --git a/packages/nodes/src/slab/floorplan-affordances.ts b/packages/nodes/src/slab/floorplan-affordances.ts index b7554530..dfedcf5c 100644 --- a/packages/nodes/src/slab/floorplan-affordances.ts +++ b/packages/nodes/src/slab/floorplan-affordances.ts @@ -37,6 +37,7 @@ const slabSnapOptions = { excludeId: node.id, nodes: sceneNodes, altKey: modifiers.altKey, + shiftKey: modifiers.shiftKey, }).point }, } diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index 19b36c24..df684b54 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -167,14 +167,17 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { const onGridMove = (event: GridEvent) => { if (isFloorplanSourcedEvent(event)) return const gridStep = getSegmentGridStep() + const bypassSnap = event.nativeEvent?.shiftKey === true const [localX, localZ] = snapFenceDraftPoint({ point: [event.localPosition[0], event.localPosition[2]], walls: levelWalls, fences: levelFences, + bypassSnap, gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep), }) if ( + !bypassSnap && previousGridPosRef.current && (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { @@ -190,8 +193,8 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { // Figma-style alignment snap: align the slab's translated polygon // vertices to other objects' anchors; fold the snap into the delta and - // publish a guide. Alt bypasses. - const bypass = event.nativeEvent?.altKey === true + // publish a guide. Alt bypasses alignment; Shift bypasses all snap. + const bypass = event.nativeEvent?.altKey === true || bypassSnap if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignmentForActiveBuilding({ moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)), diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 1f734cab..57c95298 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -1,6 +1,13 @@ 'use client' -import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' +import { + DEFAULT_ANGLE_STEP, + emitter, + type GridEvent, + type LevelNode, + snapPointAlongAngleRay, + useScene, +} from '@pascal-app/core' import { CursorSphere, clearSlabSnapFeedback, @@ -20,7 +27,7 @@ import { SlabNode } from './schema' * * Multi-click polygon drawing: each click adds a vertex; clicking near * the first vertex (or double-clicking) closes the polygon and creates - * the slab. Shift-modifier defeats the axis/45° snap during drag. + * the slab. Shift-modifier defeats the 15° angle snap during drag. * * Not a `DragAction` — same reasoning as `tool.tsx` for fence: this is * a stateful sequence of grid:click events with preview state, not a @@ -29,28 +36,6 @@ import { SlabNode } from './schema' const Y_OFFSET = 0.02 -function calculateSnapPoint( - lastPoint: [number, number], - currentPoint: [number, number], -): [number, number] { - const [x1, y1] = lastPoint - const [x, y] = currentPoint - const dx = x - x1 - const dy = y - y1 - const absDx = Math.abs(dx) - const absDy = Math.abs(dy) - const horizontalDist = absDy - const verticalDist = absDx - const diagonalDist = Math.abs(absDx - absDy) - const minDist = Math.min(horizontalDist, verticalDist, diagonalDist) - if (minDist === diagonalDist) { - const diagonalLength = Math.min(absDx, absDy) - return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength] - } - if (minDist === horizontalDist) return [x, y1] - return [x1, y] -} - function commitSlabDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string { const { createNode, nodes } = useScene.getState() const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length @@ -90,24 +75,36 @@ export const SlabTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] + const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true const gridX = Math.round(rawPoint[0] * 2) / 2 const gridZ = Math.round(rawPoint[1] * 2) / 2 - const gridPosition: [number, number] = [gridX, gridZ] + const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const lastPoint = points[points.length - 1] - const orthoPoint = - shiftPressed.current || !lastPoint + // 15° angle snap from the raw cursor (matching the 2D floorplan + // pipeline) with the distance snapped along the ray to the grid step. + const orthoPoint: [number, number] = + bypassSnap || !lastPoint ? gridPosition - : calculateSnapPoint(lastPoint, gridPosition) + : [ + ...snapPointAlongAngleRay( + lastPoint, + rawPoint, + DEFAULT_ANGLE_STEP, + useEditor.getState().gridSnapStep, + ), + ] const displayPoint = resolveSlabPlanPointSnap({ rawPoint, fallbackPoint: orthoPoint, levelId: currentLevelId, altKey: event.nativeEvent?.altKey === true, + shiftKey: bypassSnap, }).point setSnappedCursorPosition(displayPoint) if ( + !bypassSnap && points.length > 0 && previousSnappedPointRef.current && (displayPoint[0] !== previousSnappedPointRef.current[0] || @@ -163,8 +160,12 @@ export const SlabTool: React.FC = () => { const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false } + const onWindowBlur = () => { + shiftPressed.current = false + } document.addEventListener('keydown', onKeyDown) document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onWindowBlur) emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) @@ -174,6 +175,7 @@ export const SlabTool: React.FC = () => { return () => { document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onWindowBlur) emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) diff --git a/packages/nodes/src/solar-panel/move-tool.tsx b/packages/nodes/src/solar-panel/move-tool.tsx index c17973c7..402a525e 100644 --- a/packages/nodes/src/solar-panel/move-tool.tsx +++ b/packages/nodes/src/solar-panel/move-tool.tsx @@ -100,7 +100,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 - if (sx !== lastSnapX || sz !== lastSnapZ) { + if (event.nativeEvent?.shiftKey !== true && (sx !== lastSnapX || sz !== lastSnapZ)) { triggerSFX('sfx:grid-snap') lastSnapX = sx lastSnapZ = sz diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index 9289e230..1fd468cc 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -72,7 +72,7 @@ const SolarPanelTool = () => { const sx = Math.round(wx * 20) / 20 const sz = Math.round(wz * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index fc7b7c8e..69f4475b 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -22,15 +22,23 @@ function getExistingSpawnIds() { .sort() } -function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] { +function getLevelLocalPosition( + levelId: string, + event: GridEvent, + bypassSnap: boolean, +): [number, number, number] { const levelObject = sceneRegistry.nodes.get(levelId) if (!levelObject) { - return [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])] + return bypassSnap + ? [event.localPosition[0], 0, event.localPosition[2]] + : [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])] } worldVector.set(event.position[0], event.position[1], event.position[2]) levelObject.updateWorldMatrix(true, false) levelObject.worldToLocal(worldVector) - return [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)] + return bypassSnap + ? [worldVector.x, 0, worldVector.z] + : [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)] } /** @@ -52,8 +60,9 @@ const SpawnTool = () => { // Cursor lives in the ToolManager's building-local group. Use // event.localPosition directly (already building-local) with the // same half-meter snap the legacy tool uses. - const nextX = roundToHalf(event.localPosition[0]) - const nextZ = roundToHalf(event.localPosition[2]) + const bypassSnap = event.nativeEvent?.shiftKey === true + const nextX = bypassSnap ? event.localPosition[0] : roundToHalf(event.localPosition[0]) + const nextZ = bypassSnap ? event.localPosition[2] : roundToHalf(event.localPosition[2]) const position: [number, number, number] = [nextX, 0, nextZ] const previewNode = SpawnNode.parse({ name: 'Spawn Point', @@ -72,14 +81,14 @@ const SpawnTool = () => { // not every frame the mouse moves within the same cell. Matches the // wall / slab / curve tools. const prev = previousSnapRef.current - if (!prev || prev[0] !== nextX || prev[1] !== nextZ) { + if (!bypassSnap && (!prev || prev[0] !== nextX || prev[1] !== nextZ)) { triggerSFX('sfx:grid-snap') previousSnapRef.current = [nextX, nextZ] } } const onGridClick = (event: GridEvent) => { - const next = getLevelLocalPosition(activeLevelId, event) + const next = getLevelLocalPosition(activeLevelId, event, event.nativeEvent?.shiftKey === true) const [existingSpawnId, ...duplicates] = getExistingSpawnIds() let placedId: SpawnNode['id'] diff --git a/packages/nodes/src/stair/floorplan-move.ts b/packages/nodes/src/stair/floorplan-move.ts index e7b13a4d..1b86fc96 100644 --- a/packages/nodes/src/stair/floorplan-move.ts +++ b/packages/nodes/src/stair/floorplan-move.ts @@ -43,7 +43,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node, const step = getSegmentGridStep() const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step)) const [gx, gz] = resolveCursor(planPoint, { snap }) - // Figma alignment on the actual stair footprint (Alt bypasses), + // Figma alignment on the actual stair footprint (Alt bypasses alignment; Shift all snap), // matching the 3D move tool. Publishes guides via `useAlignmentGuides`. const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0) const { point: aligned } = applyFloorplanAlignment( @@ -52,7 +52,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node, ? movingAnchors : [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }], candidates, - { bypass: modifiers.altKey }, + { bypass: modifiers.altKey || modifiers.shiftKey }, ) const sx = aligned[0] const sz = aligned[1] diff --git a/packages/nodes/src/turbine-vent/move-tool.tsx b/packages/nodes/src/turbine-vent/move-tool.tsx index 3f45e9bb..d0f159d9 100644 --- a/packages/nodes/src/turbine-vent/move-tool.tsx +++ b/packages/nodes/src/turbine-vent/move-tool.tsx @@ -67,7 +67,10 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 - if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { + if ( + event.nativeEvent?.shiftKey !== true && + (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) + ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } diff --git a/packages/nodes/src/turbine-vent/tool.tsx b/packages/nodes/src/turbine-vent/tool.tsx index 5c88bb93..4a7c2bd2 100644 --- a/packages/nodes/src/turbine-vent/tool.tsx +++ b/packages/nodes/src/turbine-vent/tool.tsx @@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' import { turbineVentDefinition } from './definition' import TurbineVentPreview from './preview' @@ -33,6 +33,7 @@ const TurbineVentTool = () => { const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) const [previewYaw, setPreviewYaw] = useState(0) + const [previewRotation, setPreviewRotation] = useState(0) const lastSnapRef = useRef<[number, number] | null>(null) const previewNode = useMemo( @@ -41,9 +42,9 @@ const TurbineVentTool = () => { ...turbineVentDefinition.defaults(), name: 'Turbine Vent', position: [0, 0, 0], - rotation: 0, + rotation: previewRotation, }), - [], + [previewRotation], ) useEffect(() => { @@ -65,7 +66,7 @@ const TurbineVentTool = () => { const sx = Math.round(wx * 20) / 20 const sz = Math.round(wz * 20) / 20 const prev = lastSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } @@ -76,6 +77,7 @@ const TurbineVentTool = () => { const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) + setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) event.stopPropagation() } @@ -95,7 +97,7 @@ const TurbineVentTool = () => { name: 'Turbine Vent', roofSegmentId: hit.segment.id, position: [hit.localX, hit.localY, hit.localZ], - rotation: 0, + rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), }) state.createNode(vent, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) diff --git a/packages/nodes/src/wall/curve-tool.tsx b/packages/nodes/src/wall/curve-tool.tsx index 08bce22a..f1cd2e0c 100644 --- a/packages/nodes/src/wall/curve-tool.tsx +++ b/packages/nodes/src/wall/curve-tool.tsx @@ -85,11 +85,12 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { + const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true const snapStep = getSegmentGridStep() // Snap the cursor on the WORLD XZ grid (still in building-local // coords for the rest of the math) so a rotated building doesn't // pull the curve handle off the visible grid lines. - const [snappedLocalX, snappedLocalZ] = shiftPressedRef.current + const [snappedLocalX, snappedLocalZ] = bypassSnap ? [event.localPosition[0], event.localPosition[2]] : snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep) const localX = snappedLocalX @@ -99,7 +100,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { (localX - chord.midpoint.x) * chord.normal.x + (localZ - chord.midpoint.y) * chord.normal.y ) - const snappedOffset = shiftPressedRef.current + const snappedOffset = bypassSnap ? offsetFromMidpoint : snapScalarToGrid(offsetFromMidpoint, snapStep) const nextCurveOffset = normalizeWallCurveOffset( @@ -108,6 +109,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { ) if ( + !bypassSnap && previousCurveOffsetRef.current !== null && nextCurveOffset !== previousCurveOffsetRef.current ) { diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 2462a262..0e0face2 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -108,7 +108,7 @@ export const wallDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Set wall start / end' }, - { key: 'Shift', label: 'Allow non-45° angles' }, + { key: 'Shift', label: 'Free angle (no 15° snap)' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index 85bad124..ced8ea75 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -18,7 +18,6 @@ import { snapScalarToGrid, snapWallDraftPoint, useAlignmentGuides, - WALL_FINE_GRID_STEP, WALL_GRID_STEP, type WallPlanPoint, } from '@pascal-app/editor' @@ -187,23 +186,22 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { // the legacy flow. const sceneNodes = useScene.getState().nodes const walls = collectLevelWalls(sceneNodes, node.id) - // Endpoint move = grid snap, never 45° from the fixed corner — - // the angle snap is for initial draft only. Shift switches to - // the fine grid step for precision, matching the 3D - // `MoveWallEndpointTool`. - const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP + // Endpoint move = grid snap, never 45° from the fixed corner. + // Shift bypasses grid, magnetic, and alignment snap. const snapped = snapWallDraftPoint({ point: planPoint as WallPlanPoint, walls, ignoreWallIds: [node.id], - step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined, - gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep), + bypassSnap: modifiers.shiftKey, + magnetic: !modifiers.shiftKey, + gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP), }) // Figma-style alignment on the dragged corner — snaps it onto another // object's edge / wall face and publishes a guide. The dragged wall // and its linked siblings (which cascade with the corner) are excluded // from the candidate pool. Alt is reserved for detach, NOT bypass. const aligned = alignFloorplanDraftPoint(snapped, { + bypass: modifiers.shiftKey, excludeIds: [node.id, ...linkedWalls.map((w) => w.id)], }) as WallPlanPoint diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index 2f5f56e0..02d3ec8c 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -28,7 +28,6 @@ import { useAlignmentGuides, useEditor, useWallSnapIndicator, - WALL_FINE_GRID_STEP, type WallPlanPoint, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' @@ -288,16 +287,15 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ // drag by warping the endpoint onto the nearest 45° line from // the fixed corner. // - // Shift switches to the *fine* grid step (`WALL_FINE_GRID_STEP`) - // for precision placement, so it can land on positions the - // active grid would skip (e.g. 0.05m increments when the active - // grid is 0.5m). It does NOT bypass snap. + // Shift is a hard snap bypass: raw endpoint position, no grid, + // no magnetic wall snap, and no alignment guide snap. + const bypassSnap = shiftPressedRef.current || event.nativeEvent.shiftKey const snapResult = snapWallDraftPointDetailed({ point: planPoint, walls: levelWalls, ignoreWallIds: [nodeId], - step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined, - magnetic: useEditor.getState().magneticSnap, + bypassSnap, + magnetic: !bypassSnap && useEditor.getState().magneticSnap, }) const snappedPoint = snapResult.point @@ -308,7 +306,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ // midpoint), never an empty-space bbox corner. Layered on top of the // grid + corner snap above; Alt is reserved for corner-detach here. let alignedPoint = snappedPoint - if (wallAlignmentCandidates.length > 0) { + if (!bypassSnap && wallAlignmentCandidates.length > 0) { const ar = resolveAlignment({ moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }], candidates: wallAlignmentCandidates, @@ -318,9 +316,12 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz] } useAlignmentGuides.getState().set(ar.guides) + } else { + useAlignmentGuides.getState().clear() } if ( + !bypassSnap && previousGridPosRef.current && (alignedPoint[0] !== previousGridPosRef.current[0] || alignedPoint[1] !== previousGridPosRef.current[1]) diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 9e4b700f..1b327558 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -437,6 +437,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { + const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true const rawX = event.localPosition[0] const rawZ = event.localPosition[2] const snapStep = getSegmentGridStep() @@ -467,11 +468,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { if (axis) { const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1] const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1] - const snappedProj = shiftPressedRef.current ? rawProj : snapScalarToGrid(rawProj, snapStep) + const snappedProj = bypassSnap ? rawProj : snapScalarToGrid(rawProj, snapStep) const perpDelta = snappedProj - originalProj deltaX = axis[0] * perpDelta deltaZ = axis[1] * perpDelta - } else if (shiftPressedRef.current) { + } else if (bypassSnap) { deltaX = rawDeltaX deltaZ = rawDeltaZ } else { @@ -491,6 +492,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ] if ( + !bypassSnap && previousGridPosRef.current && (constrainedGridPos[0] !== previousGridPosRef.current[0] || constrainedGridPos[1] !== previousGridPosRef.current[1]) diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 8d1b0b97..25b02bda 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -26,8 +26,8 @@ import { triggerSFX, useAlignmentGuides, useEditor, + useSegmentDraftChain, useWallSnapIndicator, - WALL_FINE_GRID_STEP, type WallPlanPoint, } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' @@ -532,6 +532,7 @@ export const WallTool: React.FC = () => { setAxisGuide(null) useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() + useSegmentDraftChain.getState().clear('wall') } const onGridMove = (event: GridEvent) => { @@ -539,20 +540,21 @@ export const WallTool: React.FC = () => { const walls = getCurrentLevelWalls() const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - // Default to the active grid step; Shift switches to the fine - // step (0.05m) for precision. No 45° angle snap — we want the - // cursor to track grid lines in every direction. Orthogonal - // walls fall out of grid snap naturally when the start sits on - // a grid intersection. - const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined - const bypassAlign = event.nativeEvent?.altKey === true + // Default path: grid + magnetic snap, with 15° angle lock while + // drafting. Shift is a hard snap bypass: no grid, magnetic, angle, + // or alignment snap. + const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true + const angleLocked = buildingState.current === 1 && !bypassSnap + const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap const snapResult = snapWallDraftPointDetailed({ point: localPoint, walls, - step, - magnetic: useEditor.getState().magneticSnap, + start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, + angleSnap: angleLocked, + bypassSnap, + magnetic: !bypassSnap && useEditor.getState().magneticSnap, }) - gridPosition = alignPoint(snapResult.point, bypassAlign) + gridPosition = alignPoint(snapResult.point, bypassAlign || angleLocked) // Stand the magnetic beacon at the endpoint when it locked onto an // existing wall corner / wall point; clear it for plain grid/angle moves. useWallSnapIndicator @@ -579,6 +581,7 @@ export const WallTool: React.FC = () => { const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] if ( + !bypassSnap && previousWallEnd && (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1]) ) { @@ -611,6 +614,8 @@ export const WallTool: React.FC = () => { } const onGridClick = (event: GridEvent) => { + if (!wallPreviewRef.current) return + if (buildingState.current === 1 && event.nativeEvent.detail >= 2) { stopDrafting() return @@ -619,16 +624,16 @@ export const WallTool: React.FC = () => { const walls = getCurrentLevelWalls() const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined - const bypassAlign = event.nativeEvent?.altKey === true + const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true + const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap if (buildingState.current === 0) { const snappedStart = alignPoint( snapWallDraftPointDetailed({ point: localClick, walls, - step: clickStep, - magnetic: useEditor.getState().magneticSnap, + bypassSnap, + magnetic: !bypassSnap && useEditor.getState().magneticSnap, }).point, bypassAlign, ) @@ -651,14 +656,17 @@ export const WallTool: React.FC = () => { // `onGridMove` writes a real BoxGeometry skips that frame. setDraftMeasurement(null) } else if (buildingState.current === 1) { + const angleLocked = !bypassSnap const snappedEnd = alignPoint( snapWallDraftPointDetailed({ point: localClick, walls, - step: clickStep, - magnetic: useEditor.getState().magneticSnap, + start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, + angleSnap: angleLocked, + bypassSnap, + magnetic: !bypassSnap && useEditor.getState().magneticSnap, }).point, - bypassAlign, + bypassAlign || angleLocked, ) const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z @@ -684,6 +692,10 @@ export const WallTool: React.FC = () => { } const nextStart = createdWall.end + // Publish the resolved chain start so the 2D floor-plan draft + // chains its next segment from the same point (its own snap + // pipeline can resolve a slightly different endpoint). + useSegmentDraftChain.getState().setChainStart('wall', [nextStart[0], nextStart[1]]) startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) endingPoint.current.copy(startingPoint.current) cursorRef.current?.position.copy(startingPoint.current) @@ -698,7 +710,9 @@ export const WallTool: React.FC = () => { // BoxGeometry stays visible for a frame on top of the // freshly-committed real wall, producing a brief // double-paint at the new wall's position. - wallPreviewRef.current.visible = false + if (wallPreviewRef.current) { + wallPreviewRef.current.visible = false + } setDraftMeasurement(null) } } @@ -711,6 +725,12 @@ export const WallTool: React.FC = () => { if (e.key === 'Shift') shiftPressed.current = false } + // Cmd-tabbing away mid-draft never delivers the keyup — reset so the + // angle lock isn't stuck off when focus returns. + const onBlur = () => { + shiftPressed.current = false + } + const onCancel = () => { if (buildingState.current === 1) { markToolCancelConsumed() @@ -723,6 +743,7 @@ export const WallTool: React.FC = () => { emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) return () => { emitter.off('grid:move', onGridMove) @@ -730,8 +751,10 @@ export const WallTool: React.FC = () => { emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() + useSegmentDraftChain.getState().clear('wall') } }, [unit]) diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 24a9c8a0..cfa3c187 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -79,16 +79,17 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // Figma-style along-wall alignment first (edge-to-edge with other // openings / wall ends), winning over the 0.5m grid snap; falls back - // to grid when nothing aligns. Alt bypasses; Shift drops the grid snap. - const neighborX = modifiers.altKey - ? null - : snapLocalXToNeighbors({ - wall: hit.wall, - localX: hit.localX, - width: node.width, - selfId: node.id as AnyNodeId, - nodes, - }) + // to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap. + const neighborX = + modifiers.altKey || modifiers.shiftKey + ? null + : snapLocalXToNeighbors({ + wall: hit.wall, + localX: hit.localX, + width: node.width, + selfId: node.id as AnyNodeId, + nodes, + }) const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) const { clampedX, clampedY } = clampToWall( hit.wall, diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 5da0e2fa..352c6e90 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -187,23 +187,31 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const rawLocalX = event.localPosition[0] const rawLocalY = event.localPosition[1] if (!dragAnchor || dragAnchor.wallId !== event.node.id) { + const bypassSnap = event.nativeEvent?.shiftKey === true dragAnchor = { wallId: event.node.id, rawX: rawLocalX, rawY: rawLocalY, startX: event.node.id === original.parentId ? original.position[0] : rawLocalX, startY: - event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY), + event.node.id === original.parentId + ? original.position[1] + : bypassSnap + ? rawLocalY + : snapToHalf(rawLocalY), } } const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) - const targetLocalY = snapToHalf(dragAnchor.startY + (rawLocalY - dragAnchor.rawY)) + const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY) + const targetLocalY = + event.nativeEvent?.shiftKey === true ? targetRawLocalY : snapToHalf(targetRawLocalY) const localX = resolveWallSlideAlignment({ wallNode: event.node, rawLocalX: targetLocalX, width: movingWindowNode.width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, + bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassSnap: event.nativeEvent?.shiftKey === true, }) const { clampedX, clampedY } = clampToWall( event.node, @@ -409,7 +417,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode width: movingWindowNode.width, height: movingWindowNode.height, ignoreId: movingWindowNode.id, - vertical: { kind: 'free', snap: snapToHalf }, + vertical: { + kind: 'free', + snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf, + }, }) const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 2075d962..c627f7a2 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -128,9 +128,13 @@ const WindowTool: React.FC = () => { rawLocalX: event.localPosition[0], width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, + bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassSnap: event.nativeEvent?.shiftKey === true, }) - const localY = snapToHalf(event.localPosition[1]) + const localY = + event.nativeEvent?.shiftKey === true + ? event.localPosition[1] + : snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) @@ -183,9 +187,13 @@ const WindowTool: React.FC = () => { rawLocalX: event.localPosition[0], width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, + bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassSnap: event.nativeEvent?.shiftKey === true, }) - const localY = snapToHalf(event.localPosition[1]) + const localY = + event.nativeEvent?.shiftKey === true + ? event.localPosition[1] + : snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) @@ -277,9 +285,13 @@ const WindowTool: React.FC = () => { rawLocalX: event.localPosition[0], width: draftRef.current.width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, + bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, + bypassSnap: event.nativeEvent?.shiftKey === true, }) - const localY = snapToHalf(event.localPosition[1]) + const localY = + event.nativeEvent?.shiftKey === true + ? event.localPosition[1] + : snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -367,7 +379,10 @@ const WindowTool: React.FC = () => { width: draftRef.current?.width ?? 1.5, height: draftRef.current?.height ?? 1.5, ignoreId: draftRef.current?.id, - vertical: { kind: 'free', snap: snapToHalf }, + vertical: { + kind: 'free', + snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf, + }, }) const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 4624bcb8..158e4036 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -136,6 +136,13 @@ export { type StairBodyMaterials, } from './systems/stair/stair-materials' export { StairSystem } from './systems/stair/stair-system' +// Pure opening-cutout profile math shared by the wall CSG pipeline and +// roof-wall opening cuts in `@pascal-app/nodes` — keeps shaped holes +// (arch / rounded / frameless opening) identical across both hosts. +export { + buildOpeningCutoutGeometry, + hasFlatOpeningCutoutBottom, +} from './systems/wall/opening-cutout-geometry' export { WallCutout } from './systems/wall/wall-cutout' export { getVisibleWallMaterials } from './systems/wall/wall-materials' // Wall internals re-exported so `@pascal-app/nodes`' registry-driven wall diff --git a/packages/viewer/src/lib/csg-utils.test.ts b/packages/viewer/src/lib/csg-utils.test.ts new file mode 100644 index 00000000..7063565b --- /dev/null +++ b/packages/viewer/src/lib/csg-utils.test.ts @@ -0,0 +1,73 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import * as THREE from 'three' +import { ensureRenderableGeometryAttributes } from './csg-utils' + +describe('ensureRenderableGeometryAttributes', () => { + test('fills missing render attributes to match position count', () => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), 3), + ) + + ensureRenderableGeometryAttributes(geometry) + + expect(geometry.getAttribute('position')?.count).toBe(3) + expect(geometry.getAttribute('normal')?.count).toBe(3) + expect(geometry.getAttribute('uv')?.count).toBe(3) + expect(geometry.getAttribute('uv2')?.count).toBe(3) + }) + + test('replaces render attributes with the wrong item size', () => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), 3), + ) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0]), 1)) + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0]), 1)) + + ensureRenderableGeometryAttributes(geometry) + + expect(geometry.getAttribute('uv')?.itemSize).toBe(2) + expect(geometry.getAttribute('uv2')?.itemSize).toBe(2) + expect(geometry.getAttribute('uv')?.count).toBe(3) + expect(geometry.getAttribute('uv2')?.count).toBe(3) + }) + + test('copies uv into uv2 without depending on backing array layout', () => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), 3), + ) + geometry.setAttribute( + 'uv', + new THREE.InterleavedBufferAttribute( + new THREE.InterleavedBuffer(new Float32Array([0, 0, 7, 1, 0, 8, 0, 1, 9]), 3), + 2, + 0, + ), + ) + + ensureRenderableGeometryAttributes(geometry) + + const uv2 = geometry.getAttribute('uv2') + expect(Array.from(uv2.array)).toEqual([0, 0, 1, 0, 0, 1]) + }) + + test('replaces empty geometries with a degenerate renderable triangle', () => { + const geometry = new THREE.BufferGeometry() + + ensureRenderableGeometryAttributes(geometry) + + expect(geometry.getIndex()).toBeNull() + expect(geometry.groups).toHaveLength(0) + expect(geometry.getAttribute('position')?.count).toBe(3) + expect(geometry.getAttribute('normal')?.count).toBe(3) + expect(geometry.getAttribute('uv')?.count).toBe(3) + expect(geometry.getAttribute('uv2')?.count).toBe(3) + }) +}) diff --git a/packages/viewer/src/lib/csg-utils.ts b/packages/viewer/src/lib/csg-utils.ts index 478eb3be..982b3a02 100644 --- a/packages/viewer/src/lib/csg-utils.ts +++ b/packages/viewer/src/lib/csg-utils.ts @@ -1,4 +1,4 @@ -import type * as THREE from 'three' +import * as THREE from 'three' import { type Brush, Evaluator } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' @@ -10,8 +10,81 @@ import { computeBoundsTree } from 'three-mesh-bvh' * in `@pascal-app/nodes` import these through the public surface. */ +function zeroAttribute(count: number, itemSize: number) { + return new THREE.Float32BufferAttribute(new Float32Array(count * itemSize), itemSize) +} + +function upNormalAttribute(count: number) { + const values = new Float32Array(count * 3) + for (let index = 0; index < count; index += 1) { + values[index * 3 + 1] = 1 + } + return new THREE.Float32BufferAttribute(values, 3) +} + +function ensureAttributeCount( + geometry: THREE.BufferGeometry, + name: string, + itemSize: number, + count: number, +) { + const attribute = geometry.getAttribute(name) + if (attribute?.count === count && attribute.itemSize === itemSize) return + + geometry.setAttribute(name, zeroAttribute(count, itemSize)) +} + +function copyVec2Attribute(attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute) { + const values = new Float32Array(attribute.count * 2) + for (let index = 0; index < attribute.count; index += 1) { + values[index * 2] = attribute.getX(index) + values[index * 2 + 1] = attribute.getY(index) + } + return new THREE.Float32BufferAttribute(values, 2) +} + +export function ensureRenderableGeometryAttributes( + geometry: THREE.BufferGeometry, +): THREE.BufferGeometry { + const position = geometry.getAttribute('position') + if (!position || position.count === 0 || position.itemSize !== 3) { + geometry.setIndex(null) + geometry.clearGroups() + geometry.setAttribute('position', zeroAttribute(3, 3)) + geometry.setAttribute('normal', upNormalAttribute(3)) + geometry.setAttribute('uv', zeroAttribute(3, 2)) + geometry.setAttribute('uv2', zeroAttribute(3, 2)) + return geometry + } + + const count = position.count + const normal = geometry.getAttribute('normal') + if (normal?.count !== count || normal.itemSize !== 3) { + geometry.deleteAttribute('normal') + try { + geometry.computeVertexNormals() + } catch { + geometry.deleteAttribute('normal') + } + } + + const computedNormal = geometry.getAttribute('normal') + if (computedNormal?.count !== count || computedNormal.itemSize !== 3) { + geometry.setAttribute('normal', upNormalAttribute(count)) + } + ensureAttributeCount(geometry, 'uv', 2, count) + + const uv = geometry.getAttribute('uv') + const uv2 = geometry.getAttribute('uv2') + if (uv2?.count !== count || uv2.itemSize !== 2) { + geometry.setAttribute('uv2', copyVec2Attribute(uv)) + } + + return geometry +} + export function csgGeometry(brush: Brush): THREE.BufferGeometry { - return brush.geometry as unknown as THREE.BufferGeometry + return ensureRenderableGeometryAttributes(brush.geometry as unknown as THREE.BufferGeometry) } export function csgMaterials(brush: Brush): THREE.Material[] { @@ -22,7 +95,7 @@ export function csgMaterials(brush: Brush): THREE.Material[] { export const csgEvaluator = new Evaluator() csgEvaluator.useGroups = true ;(csgEvaluator as unknown as { consolidateGroups: boolean }).consolidateGroups = false -csgEvaluator.attributes = ['position', 'normal', 'uv'] +csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'] export function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { ;(geometry as unknown as { computeBoundsTree: typeof computeBoundsTree }).computeBoundsTree = @@ -33,6 +106,7 @@ export function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { } export function prepareBrushForCSG(brush: Brush) { + ensureRenderableGeometryAttributes(brush.geometry) computeGeometryBoundsTree(brush.geometry) brush.updateMatrixWorld() } diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 090971fa..5229c603 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -86,10 +86,14 @@ export const glassMaterial = new MeshLambertNodeMaterial({ side: THREE.FrontSide, }) +function resolveNodeMaterialSide(side: THREE.Side): THREE.Side { + return side === THREE.DoubleSide ? THREE.FrontSide : side +} + const sideMap: Record = { front: THREE.FrontSide, back: THREE.BackSide, - double: THREE.DoubleSide, + double: THREE.FrontSide, } const materialCache = new Map() @@ -366,12 +370,13 @@ function applyMaterialMapProperties( } material.transparent = mapProperties.transparent material.opacity = mapProperties.opacity - material.side = + material.side = resolveNodeMaterialSide( mapProperties.side === 0 ? THREE.FrontSide : mapProperties.side === 1 ? THREE.BackSide - : THREE.DoubleSide + : THREE.DoubleSide, + ) applyTexturePropertiesToMaterial(material, mapProperties) material.needsUpdate = true } @@ -487,10 +492,11 @@ export function createDefaultMaterial( shading: RenderShading = 'rendered', side: THREE.Side = THREE.FrontSide, ): THREE.Material { + const resolvedSide = resolveNodeMaterialSide(side) if (shading === 'solid') { return new MeshLambertNodeMaterial({ color, - side, + side: resolvedSide, }) } @@ -498,7 +504,7 @@ export function createDefaultMaterial( color, roughness, metalness: 0, - side, + side: resolvedSide, }) } @@ -532,7 +538,8 @@ export function createSurfaceRoleMaterial( // on both gable faces on the first frame). Callers that need both sides // visible (e.g. dormer back gable) must rotate the host mesh 180° so the // FrontSide faces the viewer. - const resolvedSide = role === 'glazing' ? THREE.FrontSide : side + const resolvedSide = + role === 'glazing' ? THREE.FrontSide : resolveNodeMaterialSide(side ?? THREE.FrontSide) const cacheKey = `${role}-${preset}-${resolvedSide}-${sceneThemeId ?? 'base'}` const cached = surfaceRoleMaterialCache.get(cacheKey) if (cached) return cached diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 93615032..3b5fd63c 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -135,6 +135,7 @@ export function generateCeilingGeometry( degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) degenerate.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) degenerate.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) + degenerate.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) return degenerate } diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 323cca7a..3df1ad1d 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -17,6 +17,7 @@ import * as THREE from 'three' import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' +import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' function csgGeometry(brush: Brush): THREE.BufferGeometry { return brush.geometry as unknown as THREE.BufferGeometry @@ -30,7 +31,7 @@ function csgMaterials(brush: Brush): THREE.Material[] { const csgEvaluator = new Evaluator() csgEvaluator.useGroups = true ;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash -csgEvaluator.attributes = ['position', 'normal', 'uv'] +csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'] function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { ;(geometry as any).computeBoundsTree = computeBoundsTree @@ -38,6 +39,7 @@ function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { } function prepareBrushForCSG(brush: Brush) { + ensureRenderableGeometryAttributes(brush.geometry) computeGeometryBoundsTree(brush.geometry) brush.updateMatrixWorld() } @@ -176,6 +178,10 @@ export const RoofSystem = () => { 'uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2), ) + placeholder.setAttribute( + 'uv2', + new THREE.Float32BufferAttribute(new Float32Array(6), 2), + ) computeGeometryBoundsTree(placeholder) mesh.geometry = placeholder } @@ -299,6 +305,7 @@ function subtractAccessoryCuts( welded.clearGroups() welded.addGroup(0, idxCount, 0) welded.computeVertexNormals() + ensureRenderableGeometryAttributes(welded) computeGeometryBoundsTree(welded) const cut = new Brush(welded, dummyMats[0]) cut.updateMatrixWorld() @@ -434,11 +441,16 @@ function updateMergedRoofGeometry( if (totalShinSlab && totalDeckSlab && totalWall && totalInner) { try { const finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION) + prepareBrushForCSG(finalShinTrimmed) const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION) + prepareBrushForCSG(finalDeckTrimmed) const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) + prepareBrushForCSG(finalWallTrimmed) const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION) + prepareBrushForCSG(shinDeck) const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) + prepareBrushForCSG(combined) const resultGeo = csgGeometry(combined) if (geometryHasNaNPositions(resultGeo)) { @@ -472,7 +484,7 @@ function updateMergedRoofGeometry( } resultGeo.computeVertexNormals() - ensureUv2Attribute(resultGeo) + ensureRenderableGeometryAttributes(resultGeo) mergedMesh.geometry.dispose() mergedMesh.geometry = resultGeo @@ -765,6 +777,7 @@ export function getRoofSegmentBrushes( // when a group exists but covers no triangles (can happen after mergeVertices) geo.groups = geo.groups.filter((g) => g.count > 0) if (geo.groups.length === 0) return null + ensureRenderableGeometryAttributes(geo) computeGeometryBoundsTree(geo) const brush = new Brush(geo, dummyMats) brush.updateMatrixWorld() @@ -810,7 +823,9 @@ export function getRoofSegmentBrushes( if (deckTopBrush && deckBotBrush && wallBrush && innerBrush && shinTopBrush && shinBotBrush) { try { const deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION) + prepareBrushForCSG(deckSlab) const shinSlab = csgEvaluator.evaluate(shinTopBrush, shinBotBrush, SUBTRACTION) + prepareBrushForCSG(shinSlab) deckTopBrush.geometry.dispose() deckBotBrush.geometry.dispose() @@ -852,8 +867,11 @@ export function generateRoofSegmentGeometry( try { const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION) + prepareBrushForCSG(hollowWall) const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION) + prepareBrushForCSG(shinDeck) const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) + prepareBrushForCSG(combined) resultGeo = csgGeometry(combined) @@ -889,7 +907,7 @@ export function generateRoofSegmentGeometry( innerBrush.geometry.dispose() resultGeo.computeVertexNormals() - ensureUv2Attribute(resultGeo) + ensureRenderableGeometryAttributes(resultGeo) return resultGeo } @@ -1296,7 +1314,7 @@ function createGeometryFromFaces( const mergedGeo = mergeVertices(geometry, 1e-4) geometry.dispose() - ensureUv2Attribute(mergedGeo) + ensureRenderableGeometryAttributes(mergedGeo) return mergedGeo } @@ -1330,13 +1348,6 @@ function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) uvs.push(_uvFaceNormal.z >= 0 ? point.x : -point.x, -point.y) } -function ensureUv2Attribute(geometry: THREE.BufferGeometry) { - const uv = geometry.getAttribute('uv') - if (!uv) return - - geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) -} - // ─── Skylight cutout ───────────────────────────────────────────────── export type SurfaceFrame = { point: THREE.Vector3 diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index f5d09f7c..19bd62a2 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -533,6 +533,7 @@ function createEmptyGeometry(): THREE.BufferGeometry { geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX) geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX) return geometry diff --git a/packages/viewer/src/systems/wall/opening-cutout-geometry.test.ts b/packages/viewer/src/systems/wall/opening-cutout-geometry.test.ts new file mode 100644 index 00000000..dc25c38b --- /dev/null +++ b/packages/viewer/src/systems/wall/opening-cutout-geometry.test.ts @@ -0,0 +1,210 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { DoorNode, WindowNode } from '@pascal-app/core' +import type * as THREE from 'three' +import { + buildOpeningCutoutGeometry, + buildOpeningCutoutShape, + hasFlatOpeningCutoutBottom, +} from './opening-cutout-geometry' + +function containsPoint(points: THREE.Vector2[], x: number, y: number) { + return points.some((point) => Math.abs(point.x - x) < 1e-6 && Math.abs(point.y - y) < 1e-6) +} + +function getBounds(points: THREE.Vector2[]) { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + for (const point of points) { + minX = Math.min(minX, point.x) + maxX = Math.max(maxX, point.x) + minY = Math.min(minY, point.y) + maxY = Math.max(maxY, point.y) + } + return { minX, maxX, minY, maxY } +} + +describe('buildOpeningCutoutShape', () => { + test('rectangle profile passes the rect through unchanged', () => { + const door = DoorNode.parse({}) + const rect = { left: 1.2, right: 2.1, bottom: 0, top: 2.1 } + + const points = buildOpeningCutoutShape(door, rect).getPoints() + + expect(containsPoint(points, rect.left, rect.bottom)).toBe(true) + expect(containsPoint(points, rect.right, rect.bottom)).toBe(true) + expect(containsPoint(points, rect.right, rect.top)).toBe(true) + expect(containsPoint(points, rect.left, rect.top)).toBe(true) + + const bounds = getBounds(points) + expect(bounds.minX).toBeCloseTo(rect.left, 9) + expect(bounds.maxX).toBeCloseTo(rect.right, 9) + expect(bounds.minY).toBeCloseTo(rect.bottom, 9) + expect(bounds.maxY).toBeCloseTo(rect.top, 9) + }) + + test('door rounded profile rounds only the top corners', () => { + const door = DoorNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 }) + const rect = { left: -0.45, right: 0.45, bottom: 0, top: 2.1 } + + const points = buildOpeningCutoutShape(door, rect).getPoints() + + expect(containsPoint(points, rect.left, rect.bottom)).toBe(true) + expect(containsPoint(points, rect.right, rect.bottom)).toBe(true) + expect(containsPoint(points, rect.left, rect.top)).toBe(false) + expect(containsPoint(points, rect.right, rect.top)).toBe(false) + expect(containsPoint(points, rect.left, rect.top - 0.2)).toBe(true) + expect(containsPoint(points, rect.left + 0.2, rect.top)).toBe(true) + expect(containsPoint(points, rect.right, rect.top - 0.2)).toBe(true) + expect(containsPoint(points, rect.right - 0.2, rect.top)).toBe(true) + }) + + test('window rounded profile rounds all four corners', () => { + const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 }) + const rect = { left: -0.75, right: 0.75, bottom: 0.9, top: 2.4 } + + const points = buildOpeningCutoutShape(window, rect).getPoints() + + expect(containsPoint(points, rect.left, rect.bottom)).toBe(false) + expect(containsPoint(points, rect.right, rect.bottom)).toBe(false) + expect(containsPoint(points, rect.left, rect.top)).toBe(false) + expect(containsPoint(points, rect.right, rect.top)).toBe(false) + expect(containsPoint(points, rect.left + 0.2, rect.bottom)).toBe(true) + expect(containsPoint(points, rect.left, rect.bottom + 0.2)).toBe(true) + expect(containsPoint(points, rect.right - 0.2, rect.top)).toBe(true) + expect(containsPoint(points, rect.right, rect.top - 0.2)).toBe(true) + + const bounds = getBounds(points) + expect(bounds.minX).toBeCloseTo(rect.left, 9) + expect(bounds.maxX).toBeCloseTo(rect.right, 9) + expect(bounds.minY).toBeCloseTo(rect.bottom, 9) + expect(bounds.maxY).toBeCloseTo(rect.top, 9) + }) + + test('shared corner radius is clamped to the opening half-extent', () => { + const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 10 }) + const rect = { left: -0.75, right: 0.75, bottom: 0.9, top: 2.4 } + + const points = buildOpeningCutoutShape(window, rect).getPoints() + + // 1.5 × 1.5 opening → radius clamps to 0.75; arcs meet at edge midpoints. + expect(containsPoint(points, rect.left, rect.bottom + 0.75)).toBe(true) + expect(containsPoint(points, rect.right, rect.top - 0.75)).toBe(true) + + const bounds = getBounds(points) + expect(bounds.minX).toBeCloseTo(rect.left, 9) + expect(bounds.maxX).toBeCloseTo(rect.right, 9) + }) + + test('individual radii normalize when their sum exceeds the opening width', () => { + const window = WindowNode.parse({ + openingShape: 'rounded', + openingRadiusMode: 'individual', + openingCornerRadii: [4, 4, 0, 0], + }) + const rect = { left: -0.5, right: 0.5, bottom: 0, top: 2 } + + const points = buildOpeningCutoutShape(window, rect).getPoints() + + // Width 1 with top radii summing to 8 → scaled down to 0.5 each. + expect(containsPoint(points, rect.left, rect.top - 0.5)).toBe(true) + expect(containsPoint(points, rect.left + 0.5, rect.top)).toBe(true) + expect(containsPoint(points, rect.left, rect.bottom)).toBe(true) + expect(containsPoint(points, rect.right, rect.bottom)).toBe(true) + }) + + test('arch profile springs at top - archHeight and peaks at the rect top', () => { + const door = DoorNode.parse({ openingShape: 'arch', archHeight: 0.45 }) + const rect = { left: -0.45, right: 0.45, bottom: 0, top: 2.1 } + + const points = buildOpeningCutoutShape(door, rect).getPoints() + + expect(containsPoint(points, rect.left, rect.bottom)).toBe(true) + expect(containsPoint(points, rect.right, rect.bottom)).toBe(true) + expect(containsPoint(points, rect.right, rect.top - 0.45)).toBe(true) + expect(containsPoint(points, 0, rect.top)).toBe(true) + expect(containsPoint(points, rect.left, rect.top)).toBe(false) + expect(containsPoint(points, rect.right, rect.top)).toBe(false) + }) + + test('profiles are origin-agnostic — offset rects yield translated points', () => { + const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 }) + const centered = buildOpeningCutoutShape(window, { + left: -0.75, + right: 0.75, + bottom: -0.75, + top: 0.75, + }).getPoints() + const offset = buildOpeningCutoutShape(window, { + left: 2.25, + right: 3.75, + bottom: 0.9, + top: 2.4, + }).getPoints() + + for (const point of centered) { + expect(containsPoint(offset, point.x + 3, point.y + 1.65)).toBe(true) + } + for (const point of offset) { + expect(containsPoint(centered, point.x - 3, point.y - 1.65)).toBe(true) + } + }) +}) + +describe('buildOpeningCutoutGeometry', () => { + test('extrudes the rect through the depth, centered on the mid-plane', () => { + const door = DoorNode.parse({}) + const geometry = buildOpeningCutoutGeometry( + door, + { left: -0.45, right: 0.45, bottom: -1.05, top: 1.05 }, + 0.24, + 0.1, + ) + + geometry.computeBoundingBox() + // Float32 position buffer → ~1e-7 relative precision. + const box = geometry.boundingBox! + expect(box.min.x).toBeCloseTo(-0.45, 6) + expect(box.max.x).toBeCloseTo(0.45, 6) + expect(box.min.y).toBeCloseTo(-1.05, 6) + expect(box.max.y).toBeCloseTo(1.05, 6) + expect(box.min.z).toBeCloseTo(-0.12, 6) + expect(box.max.z).toBeCloseTo(0.12, 6) + }) +}) + +describe('hasFlatOpeningCutoutBottom', () => { + test('flat for rectangles, arches, and door rounded (top-only radii)', () => { + expect(hasFlatOpeningCutoutBottom(DoorNode.parse({}))).toBe(true) + expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'arch' }))).toBe(true) + expect(hasFlatOpeningCutoutBottom(DoorNode.parse({ openingShape: 'rounded' }))).toBe(true) + }) + + test('rounded windows depend on their bottom radii', () => { + expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'rounded' }))).toBe(false) + expect( + hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0 })), + ).toBe(true) + expect( + hasFlatOpeningCutoutBottom( + WindowNode.parse({ + openingShape: 'rounded', + openingRadiusMode: 'individual', + openingCornerRadii: [0.2, 0.2, 0, 0], + }), + ), + ).toBe(true) + expect( + hasFlatOpeningCutoutBottom( + WindowNode.parse({ + openingShape: 'rounded', + openingRadiusMode: 'individual', + openingCornerRadii: [0.2, 0.2, 0.2, 0.2], + }), + ), + ).toBe(false) + }) +}) diff --git a/packages/viewer/src/systems/wall/opening-cutout-geometry.ts b/packages/viewer/src/systems/wall/opening-cutout-geometry.ts new file mode 100644 index 00000000..ea2e8c01 --- /dev/null +++ b/packages/viewer/src/systems/wall/opening-cutout-geometry.ts @@ -0,0 +1,224 @@ +import type { DoorNode, WindowNode } from '@pascal-app/core' +import * as THREE from 'three' + +export type OpeningCutoutNode = DoorNode | WindowNode + +export type OpeningCutoutRect = { + left: number + right: number + bottom: number + top: number +} + +type CornerRadii = { + topLeft: number + topRight: number + bottomRight: number + bottomLeft: number +} + +/** + * Pure cutout profile for a shaped door / window opening. `rect` is in + * the caller's coordinate frame — the wall CSG pipeline passes wall-local + * coords, the roof-wall pipeline an origin-centered rect — so the same + * radii / arch math serves both hosts. + */ +export function buildOpeningCutoutShape( + opening: OpeningCutoutNode, + rect: OpeningCutoutRect, +): THREE.Shape { + const { left, right, bottom, top } = rect + const width = Math.max(right - left, 1e-6) + const height = Math.max(top - bottom, 1e-6) + const shape = new THREE.Shape() + + if (opening.openingShape === 'arch') { + const halfWidth = width / 2 + const centerX = (left + right) / 2 + const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height) + const springY = top - archHeight + const segments = 32 + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, springY) + for (let index = 1; index <= segments; index += 1) { + const x = right + (left - right) * (index / segments) + const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1) + const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0)) + shape.lineTo(x, y) + } + shape.lineTo(left, bottom) + shape.closePath() + return shape + } + + if (opening.openingShape === 'rounded') { + const radii = getRoundedOpeningRadii(opening, width, height) + applyRoundedOpeningShape(shape, left, right, bottom, top, radii) + return shape + } + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, top) + shape.lineTo(left, top) + shape.closePath() + return shape +} + +export function buildOpeningCutoutGeometry( + opening: OpeningCutoutNode, + rect: OpeningCutoutRect, + depth: number, + wallThickness: number, +): THREE.BufferGeometry { + const shape = buildOpeningCutoutShape(opening, rect) + const bevelSize = + opening.openingShape === 'rounded' + ? Math.min( + Math.max(opening.openingRevealRadius ?? 0.025, 0), + Math.max(wallThickness * 0.45, 0.001), + Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001), + ) + : 0 + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: bevelSize > 0, + bevelSegments: bevelSize > 0 ? 8 : 0, + bevelSize, + bevelThickness: bevelSize, + curveSegments: 24, + }) + + geometry.translate(0, 0, -depth / 2) + return geometry +} + +/** + * Whether the cutout profile's bottom edge is a flat chord. Cuts whose + * bottom sits coplanar with the host wall base get extended slightly + * downward to keep CSG away from coplanar faces — but only a flat chord + * may extend; shifting a rounded bottom would distort the profile. + */ +export function hasFlatOpeningCutoutBottom(opening: OpeningCutoutNode): boolean { + if (opening.openingShape !== 'rounded' || opening.type !== 'window') return true + + if (opening.openingRadiusMode === 'individual') { + const [, , bottomRight = 0, bottomLeft = 0] = opening.openingCornerRadii ?? [ + 0.15, 0.15, 0.15, 0.15, + ] + return bottomRight <= 1e-6 && bottomLeft <= 1e-6 + } + + return Math.max(opening.cornerRadius ?? 0.15, 0) <= 1e-6 +} + +function getRoundedOpeningRadii( + opening: OpeningCutoutNode, + width: number, + height: number, +): CornerRadii { + if (opening.type !== 'window') { + if (opening.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15] + + return normalizeCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + bottomRight: 0, + bottomLeft: 0, + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height) + const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 } + } + + if (opening.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = + opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] + + return normalizeCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + bottomRight: Math.max(bottomRight, 0), + bottomLeft: Math.max(bottomLeft, 0), + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height / 2) + const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius } +} + +function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii { + const next = { ...radii } + const maxScale = Math.min( + 1, + width / Math.max(next.topLeft + next.topRight, 1e-6), + width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), + height / Math.max(next.topLeft + next.bottomLeft, 1e-6), + height / Math.max(next.topRight + next.bottomRight, 1e-6), + ) + + if (maxScale < 1) { + next.topLeft *= maxScale + next.topRight *= maxScale + next.bottomRight *= maxScale + next.bottomLeft *= maxScale + } + + return next +} + +function applyRoundedOpeningShape( + shape: THREE.Shape, + left: number, + right: number, + bottom: number, + top: number, + radii: CornerRadii, +) { + const { topLeft, topRight, bottomRight, bottomLeft } = radii + + shape.moveTo(left + bottomLeft, bottom) + shape.lineTo(right - bottomRight, bottom) + if (bottomRight > 1e-6) { + shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false) + } else { + shape.lineTo(right, bottom) + } + + shape.lineTo(right, top - topRight) + if (topRight > 1e-6) { + shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false) + } else { + shape.lineTo(right, top) + } + + shape.lineTo(left + topLeft, top) + if (topLeft > 1e-6) { + shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false) + } else { + shape.lineTo(left, top) + } + + shape.lineTo(left, bottom + bottomLeft) + if (bottomLeft > 1e-6) { + shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false) + } else { + shape.lineTo(left, bottom) + } + + shape.closePath() +} diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 10d58413..840075a0 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -28,9 +28,12 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' +import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' +import { buildOpeningCutoutGeometry } from './opening-cutout-geometry' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() +csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'] const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 const WALL_FACE_NORMAL_Y_EPSILON = 0.6 const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003 @@ -52,13 +55,6 @@ type TaggedWallBoundaryEdge = { tag: WallBoundaryEdgeTag } -function ensureUv2Attribute(geometry: THREE.BufferGeometry) { - const uv = geometry.getAttribute('uv') - if (!uv) return - - geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) -} - function insetCurvedWallBoundaryPointsFor3D( wall: WallNode, boundaryPoints: ReturnType, @@ -671,7 +667,7 @@ export function generateExtrudedWall( geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges) - ensureUv2Attribute(geometry) + ensureRenderableGeometryAttributes(geometry) // Apply CSG subtraction for cutouts (doors/windows) const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) @@ -681,6 +677,7 @@ export function generateExtrudedWall( // Create wall brush from geometry // Pre-compute BVH with new API to avoid deprecation warning + ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) const wallBrush = new Brush(geometry) @@ -689,8 +686,9 @@ export function generateExtrudedWall( // Subtract each cutout from the wall let resultBrush = wallBrush for (const cutoutBrush of cutoutBrushes) { - cutoutBrush.updateMatrixWorld() + prepareBrushForCSG(cutoutBrush) const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION) + prepareBrushForCSG(newResult) if (resultBrush !== wallBrush) { csgGeometry(resultBrush).dispose() } @@ -706,7 +704,7 @@ export function generateExtrudedWall( const resultGeometry = csgGeometry(resultBrush) resultGeometry.computeVertexNormals() assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges) - ensureUv2Attribute(resultGeometry) + ensureRenderableGeometryAttributes(resultGeometry) return resultGeometry } @@ -799,189 +797,23 @@ function collectCutoutBrushes( return brushes } -type ShapedOpeningNode = DoorNode | WindowNode -type CornerRadii = { - topLeft: number - topRight: number - bottomRight: number - bottomLeft: number -} - -function createShapedOpeningCutoutBrush(opening: ShapedOpeningNode, wallThickness: number): Brush { - const shape = createShapedOpeningCutoutShape(opening) - const depth = wallThickness * 2 - const bevelSize = - opening.openingShape === 'rounded' - ? Math.min( - Math.max(opening.openingRevealRadius ?? 0.025, 0), - Math.max(wallThickness * 0.45, 0.001), - Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001), - ) - : 0 - const geometry = new THREE.ExtrudeGeometry(shape, { - depth, - bevelEnabled: bevelSize > 0, - bevelSegments: bevelSize > 0 ? 8 : 0, - bevelSize, - bevelThickness: bevelSize, - curveSegments: 24, - }) - - geometry.translate(0, 0, -depth / 2) +function createShapedOpeningCutoutBrush( + opening: DoorNode | WindowNode, + wallThickness: number, +): Brush { + const halfWidth = opening.width / 2 + const geometry = buildOpeningCutoutGeometry( + opening, + { + left: opening.position[0] - halfWidth, + right: opening.position[0] + halfWidth, + bottom: opening.position[1] - opening.height / 2, + top: opening.position[1] + opening.height / 2, + }, + wallThickness * 2, + wallThickness, + ) computeGeometryBoundsTree(geometry) return new Brush(geometry) } - -function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape { - const halfWidth = opening.width / 2 - const bottom = opening.position[1] - opening.height / 2 - const top = opening.position[1] + opening.height / 2 - const centerX = opening.position[0] - const left = centerX - halfWidth - const right = centerX + halfWidth - const width = Math.max(opening.width, 1e-6) - const height = Math.max(opening.height, 1e-6) - const shape = new THREE.Shape() - - if (opening.openingShape === 'arch') { - const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height) - const springY = top - archHeight - const segments = 32 - - shape.moveTo(left, bottom) - shape.lineTo(right, bottom) - shape.lineTo(right, springY) - for (let index = 1; index <= segments; index += 1) { - const x = right + (left - right) * (index / segments) - const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1) - const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0)) - shape.lineTo(x, y) - } - shape.lineTo(left, bottom) - shape.closePath() - return shape - } - - if (opening.openingShape === 'rounded') { - const radii = getRoundedOpeningRadii(opening, width, height) - applyRoundedOpeningShape(shape, left, right, bottom, top, radii) - return shape - } - - shape.moveTo(left, bottom) - shape.lineTo(right, bottom) - shape.lineTo(right, top) - shape.lineTo(left, top) - shape.closePath() - return shape -} - -function getRoundedOpeningRadii( - opening: ShapedOpeningNode, - width: number, - height: number, -): CornerRadii { - if (opening.type !== 'window') { - if (opening.openingRadiusMode === 'individual') { - const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15] - - return normalizeCornerRadii( - { - topLeft: Math.max(topLeft, 0), - topRight: Math.max(topRight, 0), - bottomRight: 0, - bottomLeft: 0, - }, - width, - height, - ) - } - - const maxRadius = Math.min(width / 2, height) - const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius) - return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 } - } - - if (opening.openingRadiusMode === 'individual') { - const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = - opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] - - return normalizeCornerRadii( - { - topLeft: Math.max(topLeft, 0), - topRight: Math.max(topRight, 0), - bottomRight: Math.max(bottomRight, 0), - bottomLeft: Math.max(bottomLeft, 0), - }, - width, - height, - ) - } - - const maxRadius = Math.min(width / 2, height / 2) - const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius) - return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius } -} - -function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii { - const next = { ...radii } - const maxScale = Math.min( - 1, - width / Math.max(next.topLeft + next.topRight, 1e-6), - width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), - height / Math.max(next.topLeft + next.bottomLeft, 1e-6), - height / Math.max(next.topRight + next.bottomRight, 1e-6), - ) - - if (maxScale < 1) { - next.topLeft *= maxScale - next.topRight *= maxScale - next.bottomRight *= maxScale - next.bottomLeft *= maxScale - } - - return next -} - -function applyRoundedOpeningShape( - shape: THREE.Shape, - left: number, - right: number, - bottom: number, - top: number, - radii: CornerRadii, -) { - const { topLeft, topRight, bottomRight, bottomLeft } = radii - - shape.moveTo(left + bottomLeft, bottom) - shape.lineTo(right - bottomRight, bottom) - if (bottomRight > 1e-6) { - shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false) - } else { - shape.lineTo(right, bottom) - } - - shape.lineTo(right, top - topRight) - if (topRight > 1e-6) { - shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false) - } else { - shape.lineTo(right, top) - } - - shape.lineTo(left + topLeft, top) - if (topLeft > 1e-6) { - shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false) - } else { - shape.lineTo(left, top) - } - - shape.lineTo(left, bottom + bottomLeft) - if (bottomLeft > 1e-6) { - shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false) - } else { - shape.lineTo(left, bottom) - } - - shape.closePath() -}