viewer: Fix Dutch roof trim artifacts (#452)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* Fix spiral stair openings and fence handle arrows

* Implement roof trim planes and ridge vent clipping

* Fix mansard roof and ridge vent placement

* Fix mansard merged roof cutouts

* Fix Dutch roof gable overhang

* Refactor roof segment, ridge vent, and surface geometry

Remove Dutch ridge axis abstraction and rework roof edit system,
ridge vent clipping geometry, and roof surface placement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Simplify Dutch roof shape

* Add Dutch roof gable top geometry controls

* Fix Dutch roof slope material slots

* Render dutch roof tops as double-sided faces

* Add auto ridge vent toggle to roof segments

Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so
geometry changes only regenerate default vents when enabled, treating
legacy segments with generated vents as auto-enabled for back-compat.
Expose a panel toggle to opt in/out per segment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Snap new walls to the floor below

Feed the walls of the level directly beneath the active one into the
draft snap pipeline as extra references, so a new wall can align with
the floor below. They share the same local XZ origin, and the list is
kept separate from the current-level walls so the measurement HUD and
wall splitting only act on the active level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Set Dutch roof shape defaults on type switch

Seed the Dutch shape parameters (waist width/height/length, top rake
thickness/length) with sensible defaults whenever a segment is created
as or switched to Dutch, so the gablet is well-formed regardless of
leftover values from the previous roof type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use green accent for corner and endpoint snap markers

Color the corner/endpoint snap markers and the vertical cursor pillar
green across the 2D floorplan beacon, the 3D alignment guide dots, and
the wall snap beacon so snap targets read as a consistent accent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add magnetic wall snapping to the roof tool

Snap roof draft corners onto wall corners, midpoints, crossings, and
bodies on the active level and the floor below, reusing the wall tool's
snap pipeline so the beacon and coloring match. The cursor's ground
dot/ring is hidden while a wall snap is active to avoid overlapping the
beacon glyph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update auto-generated Next.js route types path

Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Show cutaway outline while dragging roof trim

Slice an untrimmed segment volume generated from the live node instead of
the registry mesh, whose CSG rebuild lags a few frames behind the drag and
may still hold placeholder geometry — so the section outline now renders
deterministically. Use LineBasicNodeMaterial so the outline draws under the
WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Fill and clip roof trim cutaway, gate it to active drag

Add a violet silhouette fill behind the cutaway outline, extend the
section slicing to angled diagonal/corner trims via a generic vertical
cut plane, and clip each slice to its footprint span so the infinite
plane no longer sprouts stray lines across the rest of the roof. The
cutaway now renders only while a trim handle is being dragged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Separate and extend Dutch roof end slopes

Pull the Dutch hip end slopes out of the watertight shingle shell into
their own slab wedge so they can be reshaped independently, and extend
each end slope inward up its own hip plane until the top edge meets the
gablet's inner triangle. Refactor roof-segment shape geometry into a
shared roof-segment-shape module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Render roof trim cutaway as a material-only section cut

Replace the triangle-mesh slicer with a CSG intersection of a thin slab
against the untrimmed roof shell, so the cutaway shows red only on real
material (wall + deck bands) and leaves the hollow attic empty. Add an
analytic surface-edge outline, style both solid red like a SketchUp
section, and make the cutaway persist whenever a segment is trimmed.

Keep the merged roof shell visible during trim editing (re-trimmed live
from each segment's drag override) instead of swapping in the per-segment
meshes, whose abutting end-cap faces showed as stray white planes the
commit never had. Extend each slab past free cut-line ends only — trimmed
ends clamp to the cut line — so the red section stays inside the trim box.

Re-export INTERSECTION from the viewer CSG surface for the editor.

* Outline roof cutaway by fill silhouette, restyle to destructive red

Derive the section-cut outline from the fill geometry's edges
(EdgesGeometry) so it traces the real cut shape — wall/deck band
boundaries and the hollow-attic edge — instead of just the top surface
line. Drop the fill to 85% opacity and recolor both fill and outline to
the app's destructive red, matching the delete/destructive UI.

* Include roof accessories in trim clipping and red cutaway

Roof accessories (chimney, vents, skylight, dormer, gutter, downspout,
solar-panel, cupola) now slice at the trim plane like the roof shell and
appear in the red section-cut while dragging a trim handle:

- Export clipGeometryBySegmentTrim from the viewer as a reusable
  segment-local trim-clip primitive.
- Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh
  wrapper (nodes) that slice accessory geometry by the host segment's
  live trim override, so the cut tracks the drag.
- Wire the clip into all 11 accessory renderers, including skylight
  glass panes and dormer window glass/frame/sill.
- Feed every hosted accessory mesh into the editor's red cutaway, welding
  triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a
  cross-section.
- Register skylight in the scene-graph tree-node map so it shows in the
  outliner when placed on a roof.

Co-Authored-By: Claude <noreply@anthropic.com>

* Add smooth spline fences with editable curve handles

Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an
optional `path` (+ per-point `tangents`), selectable in a Straight/Curved
mode toggle. Selected spline fences expose draggable control-point dots
(hexagon) and symmetric tangent handles (circle) joined by a violet line,
editable in both 2D plan and 3D. Side-move arrows are dropped for splines.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix dutch roof ridge vent handling

* Fix Dutch ridge vent placement and support

* Fix Dutch roof trim artifacts

* Fix Dutch roof trim preview geometry

* Tag roof trim overlay meshes with EDITOR_LAYER

Child meshes relied on a parent group's layer, which three.js does not
propagate, so the trim section/rail/plane overlays rendered on the scene
layer — getting inked/SSGI-darkened and leaking into thumbnail exports.

Co-Authored-By: Claude <noreply@anthropic.com>

* Apply Biome cleanup

* fix(core): address Dutch roof review feedback

* chore: apply biome check cleanup

* fix(core): relax Dutch roof surface helper input

* fix

* Fix biome checks and dev verification

* fixes

* Remove unsupported Biome noShadow override

* Improve roof interactions and fence editing

* Fix fence drag and ridge vent default handling

* editor: drop wall-snap debug log, gate curved-fence finish hint on draft start

Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove
hot path.

Curved fences commit on a closing gesture (double-click / Enter) rather than
per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a
point has been placed and a curve is actually in flight. The draft point count
is published from SplineFenceDraft into a small ephemeral editor store
(useFenceCurveDraft) that the contextual helper reads, mirroring the existing
useSegmentDraftChain pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
Sudhir Yadav
2026-06-30 16:41:13 -04:00
committed by GitHub
co-authored by Claude Opus 4.8 Wassim SAMAD
parent c51e673421
commit bf25af6add
179 changed files with 10809 additions and 1467 deletions
@@ -10,7 +10,6 @@ import {
type FloorplanMoveTargetSession,
nodeRegistry,
pauseSceneHistory,
resolveAlignment,
resumeSceneHistory,
useLiveNodeOverrides,
useLiveTransforms,
@@ -22,6 +21,7 @@ import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../lib/sfx-bus'
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
@@ -455,11 +455,13 @@ export function FloorplanRegistryMoveOverlay() {
// point by the cursor delta and commit the translated `path` instead.
// The reference origin is the path centre so the SVG `translate` delta
// matches the geometry's actual location (which isn't at [0,0,0]).
// Only 3D `[x, y, z]` polyline kinds (duct / pipe / lineset) are handled
// here. A spline fence also carries a `path`, but it is 2D (`[x, y]`) and
// moves through its own `floorplanMoveTarget`, so exclude shorter tuples.
const rawPath = (movingNode as { path?: unknown }).path
const originalPath =
'path' in movingNode && Array.isArray((movingNode as { path?: unknown }).path)
? (movingNode as { path: [number, number, number][] }).path.map(
(p) => [...p] as [number, number, number],
)
Array.isArray(rawPath) && Array.isArray(rawPath[0]) && rawPath[0].length >= 3
? (rawPath as [number, number, number][]).map((p) => [...p] as [number, number, number])
: null
const originalPosition: [number, number, number] = originalPath
? (() => {
@@ -574,7 +576,7 @@ export function FloorplanRegistryMoveOverlay() {
// store, which the 2D FloorplanAlignmentGuideLayer renders
// inside the rotated scene <g>. The 3D pipeline uses a
// separate store, so frames stay isolated per surface.
const result = resolveAlignment({
const result = resolveAlignmentForFloorplanView({
moving: movingAnchors,
candidates: candidateAnchors,
threshold: ALIGNMENT_THRESHOLD_M,
@@ -14,12 +14,14 @@ import { useFloorplanRender } from './floorplan-render-context'
* endpoint (corner) → square midpoint → triangle
* intersection → ✕ cross wall body (edge) → circle
*
* Indigo to match the 3D beacon and stay distinct from the red Figma alignment
* guides. Sizes are pixel-budgeted via `unitsPerPixel` so the marker stays a
* Indigo to match the 3D beacon, except the corner (endpoint) square which is
* green to match the alignment guides. Sizes are pixel-budgeted via
* `unitsPerPixel` so the marker stays a
* constant size on screen at any zoom. Mounted inside the `data-floorplan-scene`
* group so coordinates are world meters (XZ) 1:1, like the alignment guides.
*/
const COLOR = '#6366f1' // indigo-500 — matches the 3D beacon
const ENDPOINT_COLOR = '#22c55e' // green-500 — corner (endpoint) snap accent
export const FloorplanSnapBeaconLayer = memo(function FloorplanSnapBeaconLayer() {
const point = useWallSnapIndicator((s) => s.point)
@@ -54,7 +56,7 @@ function SnapMarker({
z: number
}) {
if (kind === 'endpoint') {
return <rect fill={color} height={m * 2} width={m * 2} x={x - m} y={z - m} />
return <rect fill={ENDPOINT_COLOR} height={m * 2} width={m * 2} x={x - m} y={z - m} />
}
if (kind === 'midpoint') {
const t = m * 1.3
@@ -44,9 +44,11 @@ import { createEditorApi } from '../../../lib/editor-api'
import {
type ActiveInteractionScope,
boundaryReshapeScope,
controlPointReshapeScope,
curveReshapeScope,
endpointReshapeScope,
holeEditScope,
tangentReshapeScope,
} from '../../../lib/interaction/scope'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
@@ -151,6 +153,14 @@ function affordanceReshapeScope(
if (affordance.includes('curve')) {
return curveReshapeScope(nodeId)
}
if (affordance.includes('control-point')) {
const index = (payload as { index?: number } | undefined)?.index ?? 0
return controlPointReshapeScope(nodeId, index)
}
if (affordance.includes('tangent')) {
const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out')
}
if (affordance.includes('endpoint')) {
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
return endpointReshapeScope(nodeId, endpoint)
@@ -1,7 +1,6 @@
'use client'
import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
@@ -9,6 +8,7 @@ import { memo, useMemo, useRef } from 'react'
import { BoxGeometry, CircleGeometry, type Group, Vector3 } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import useAlignmentGuides from '../../store/use-alignment-guides'
import { formatMeasurement } from './measurement-pill'
/**
@@ -49,6 +49,14 @@ const guideMaterial = new MeshBasicNodeMaterial({
toneMapped: false,
transparent: true,
})
const DOT_COLOR = 0x22_c5_5e // green-500 — matches the wall-snap marker
const dotMaterial = new MeshBasicNodeMaterial({
color: DOT_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
})
const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
const DOT_GEOMETRY = new CircleGeometry(1, 24)
@@ -150,7 +158,7 @@ function Dot({ position }: { position: Vec3 }) {
<mesh
geometry={DOT_GEOMETRY}
layers={EDITOR_LAYER}
material={guideMaterial}
material={dotMaterial}
position={position}
renderOrder={1001}
rotation={[-Math.PI / 2, 0, 0]}
@@ -457,12 +457,7 @@ export const CustomCameraControls = () => {
return useEditor.subscribe((state) => {
const pose = state.navigationSyncPose
if (
!pose ||
pose.source !== '2d' ||
pose.revision === lastApplied2dNavigationRevision.current
)
return
if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return
const control = controls.current
if (!control) return
@@ -0,0 +1,87 @@
'use client'
import {
type AnyNodeId,
type FenceNode,
getFenceControlHandle,
isSplineFence,
sceneRegistry,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, useFrame } from '@react-three/fiber'
import { useMemo, useState } from 'react'
import { BufferGeometry, type Object3D, Vector3 } from 'three'
import { EDITOR_LAYER } from '../../lib/constants'
/**
* Straight connecting line through each spline-fence control point joining its
* two tangent handle ends (the classic pen-tool look). The handle *dots* are
* registry tap-handles (see `fence/definition.ts`); this overlay only draws
* the line between them, in both views' 3D scene.
*
* Must match the handle placement's arm scale so the line ends land exactly on
* the dots. Portals into the fence's own Object3D so it inherits the same world
* transform as the geometry (path coords are node-local plan meters), and reads
* the live override so the line tracks an in-flight tangent / point drag.
*/
// Keep in sync with TANGENT_HANDLE_ARM_SCALE in fence/definition.ts.
const TANGENT_HANDLE_ARM_SCALE = 3
const LINE_LIFT_Y = 0.02
export function FenceTangentLines3D() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const rawNode = useScene((s) => (selectedId ? s.nodes[selectedId as AnyNodeId] : null))
const liveOverride = useLiveNodeOverrides((s) =>
selectedId ? s.overrides.get(selectedId as AnyNodeId) : undefined,
)
const fence = useMemo<FenceNode | null>(() => {
if (rawNode?.type !== 'fence') return null
const merged = liveOverride ? ({ ...rawNode, ...liveOverride } as FenceNode) : rawNode
return isSplineFence(merged) ? merged : null
}, [rawNode, liveOverride])
const [object, setObject] = useState<{ id: AnyNodeId; object: Object3D } | null>(null)
const selectedObject = selectedId && object?.id === selectedId ? object.object : null
useFrame(() => {
if (!selectedId || selectedObject) return
const next = sceneRegistry.nodes.get(selectedId)
if (next) setObject({ id: selectedId as AnyNodeId, object: next })
})
const geometry = useMemo(() => {
if (!fence?.path) return null
const positions: Vector3[] = []
for (let i = 0; i < fence.path.length; i += 1) {
const point = fence.path[i]!
const handle = getFenceControlHandle(fence.path, fence.tangents, i)
const ax = handle.x * TANGENT_HANDLE_ARM_SCALE
const az = handle.y * TANGENT_HANDLE_ARM_SCALE
// One disjoint segment per point — `lineSegments` pairs vertices, so the
// in/out ends connect through the point without joining across points.
positions.push(new Vector3(point[0] - ax, LINE_LIFT_Y, point[1] - az))
positions.push(new Vector3(point[0] + ax, LINE_LIFT_Y, point[1] + az))
}
return new BufferGeometry().setFromPoints(positions)
}, [fence])
if (!(fence && selectedObject && geometry)) return null
return createPortal(
<lineSegments frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={3}>
<lineBasicNodeMaterial
color="#8381ed"
depthTest={false}
depthWrite={false}
opacity={0.85}
transparent
/>
</lineSegments>,
selectedObject,
)
}
@@ -16,8 +16,10 @@ import {
getWallCurveLength,
getWallThickness,
ItemNode,
isCurvedWall,
isRegistryMovable,
isRegistrySelectable,
isSplineFence,
nodeRegistry,
RoofSegmentNode,
type SlabNode,
@@ -691,7 +693,8 @@ export function FloatingActionMenu() {
onFind={node && canFindNode ? handleFind : undefined}
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
(node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
(node?.type === 'wall' && canCurveSelectedWall)
? handleCurve
: undefined
}
@@ -38,7 +38,7 @@ export function FloatingBuildingActionMenu() {
// Read lazily at click time — no need to subscribe to nodes for a
// one-shot action.
const node = useScene.getState().nodes[buildingId]
if (!node || node.type !== 'building') return
if (node?.type !== 'building') return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as BuildingNode)
setSelection({ buildingId: null })
@@ -54,7 +54,7 @@ import {
ZoneNode as ZoneNodeSchema,
type ZoneNode as ZoneNodeType,
} from '@pascal-app/core'
import { useAlignmentGuides, useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor'
import { useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Command, Ruler } from 'lucide-react'
import {
@@ -91,6 +91,7 @@ import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import { cn } from '../../lib/utils'
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
import useEditor, {
isAngleSnapActive,
@@ -5105,13 +5106,13 @@ export function FloorplanPanel({
const hasAmbientBuildingLevel = useScene((state) => {
if (levelId || !ambientBuildingId) return false
const building = state.nodes[ambientBuildingId]
if (!building || building.type !== 'building') return false
if (building?.type !== 'building') return false
return building.children.some((cid) => state.nodes[cid]?.type === 'level')
})
const elevators = useScene(
useShallow((state) => {
const building = currentBuildingId ? state.nodes[currentBuildingId] : null
if (!building || building.type !== 'building') {
if (building?.type !== 'building') {
return [] as ElevatorNode[]
}
@@ -5889,6 +5890,7 @@ export function FloorplanPanel({
const isOpeningMoveActive = movingOpeningType !== null
const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
const isFenceBuildActive = phase === 'structure' && mode === 'build' && tool === 'fence'
const fenceContinuation = useEditor((state) => state.continuationByContext.fence)
const isRoofBuildActive = phase === 'structure' && mode === 'build' && tool === 'roof'
const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
const isStairMoveActive = movingNode?.type === 'stair'
@@ -5945,18 +5947,16 @@ export function FloorplanPanel({
isRegistryToolBuildActive
const floorplanOpeningLocalY = useMemo(() => {
if (movingNode?.type === 'door' || movingNode?.type === 'window') {
return shiftPressed ? movingNode.position[1] : snapToHalf(movingNode.position[1])
return snapToHalf(movingNode.position[1])
}
if (isWindowBuildActive) {
// Floorplan is top-down, so new windows need an explicit wall-local height.
return shiftPressed
? FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y
: snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y)
return snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y)
}
return 0
}, [isWindowBuildActive, movingNode, shiftPressed])
}, [isWindowBuildActive, movingNode])
// Float the faithful door/window symbol at the cursor while it isn't over a
// wall (the off-wall placement ghost), by publishing a transient opening on a
// synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer`
@@ -6488,7 +6488,7 @@ export function FloorplanPanel({
// hidden; a no-op while closed (the sync early-returns).
syncFloorplanViewportToNavigationPose(pose)
}
}, [isFloorplanOpen, syncFloorplanViewportToNavigationPose])
}, [syncFloorplanViewportToNavigationPose])
useEffect(() => {
return useEditor.subscribe((state) => {
@@ -7154,7 +7154,7 @@ export function FloorplanPanel({
setCursorPoint(planPoint)
return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial<ElevatorNode>
},
[],
[setCursorPoint],
)
const handleElevatorResizePointerDown = useCallback(
@@ -7227,7 +7227,13 @@ export function FloorplanPanel({
setElevatorResizeDragState(null)
setCursorPoint(null)
},
[elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize, updateNode],
[
elevatorResizeDragState,
getPlanPointFromClientPoint,
previewElevatorResize,
updateNode,
setCursorPoint,
],
)
useEffect(() => {
@@ -7504,15 +7510,15 @@ export function FloorplanPanel({
setWallChainFirstVertex(null)
setDraftEnd(null)
useSegmentDraftChain.getState().clear('wall')
}, [])
}, [setDraftEnd])
const clearFencePlacementDraft = useCallback(() => {
setFenceDraftStart(null)
setFenceDraftEnd(null)
}, [])
}, [setFenceDraftEnd])
const clearRoofPlacementDraft = useCallback(() => {
setRoofDraftStart(null)
setRoofDraftEnd(null)
}, [])
}, [setRoofDraftEnd])
const clearCeilingPlacementDraft = useCallback(() => {
setCeilingDraftPoints([])
}, [])
@@ -7585,6 +7591,7 @@ export function FloorplanPanel({
clearWallEndpointDrag,
clearWallPlacementDraft,
clearZonePlacementDraft,
setCursorPoint,
])
useEffect(() => {
@@ -8020,14 +8027,13 @@ export function FloorplanPanel({
return
}
// Wall endpoint move: grid snap only. Shift bypasses all snap.
const bypassSnap = shiftPressed || event.shiftKey
// Wall endpoint move: snapping follows the active mode; there is no
// held-key bypass.
const snapResult = snapWallDraftPointDetailed({
point: planPoint,
walls,
ignoreWallIds: [dragState.wallId],
bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
magnetic: isMagneticSnapActive(),
})
const snappedPoint = snapResult.point
// Magnetic beacon at the endpoint when it locked onto existing geometry.
@@ -8068,7 +8074,6 @@ export function FloorplanPanel({
)
if (
!bypassSnap &&
!(
previousDraft &&
pointsEqual(previousDraft.start, nextDraft.start) &&
@@ -8097,18 +8102,12 @@ export function FloorplanPanel({
}
const chord = getWallChordFrame(wall)
const bypassSnap = shiftPressed || event.shiftKey
const snappedPoint: WallPlanPoint = bypassSnap
? planPoint
: [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
const rawCurveOffset = -(
(snappedPoint[0] - chord.midpoint.x) * chord.normal.x +
(snappedPoint[1] - chord.midpoint.y) * chord.normal.y
)
const nextCurveOffset = normalizeWallCurveOffset(
wall,
bypassSnap ? rawCurveOffset : snapToHalf(rawCurveOffset),
)
const nextCurveOffset = normalizeWallCurveOffset(wall, snapToHalf(rawCurveOffset))
if (curveDragState.currentCurveOffset === nextCurveOffset) {
return
@@ -8117,9 +8116,7 @@ export function FloorplanPanel({
curveDragState.currentCurveOffset = nextCurveOffset
setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset })
setCursorPoint(snappedPoint)
if (!bypassSnap) {
sfxEmitter.emit('sfx:grid-snap')
}
sfxEmitter.emit('sfx:grid-snap')
}
const commitGuideInteraction = (event: PointerEvent) => {
@@ -8314,6 +8311,7 @@ export function FloorplanPanel({
updateNode,
wallById,
walls,
setCursorPoint,
])
useEffect(() => {
@@ -8348,10 +8346,7 @@ export function FloorplanPanel({
return
}
const bypassSnap = shiftPressed || event.shiftKey
const snappedPoint: WallPlanPoint = bypassSnap
? planPoint
: [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
setCursorPoint(snappedPoint)
const currentDraft = siteBoundaryDraftRef.current
@@ -8364,9 +8359,7 @@ export function FloorplanPanel({
return
}
if (!bypassSnap) {
sfxEmitter.emit('sfx:grid-snap')
}
sfxEmitter.emit('sfx:grid-snap')
const nextPolygon = [...currentDraft.polygon]
nextPolygon[dragState.vertexIndex] = snappedPoint
@@ -8441,11 +8434,11 @@ export function FloorplanPanel({
exitSiteEditingToSelect,
getPlanPointFromClientPoint,
setSiteBoundaryLivePreview,
shiftPressed,
site,
siteBoundaryWorldPolygon,
siteVertexDragState,
updateNode,
setCursorPoint,
])
useEffect(() => {
@@ -8504,7 +8497,14 @@ export function FloorplanPanel({
event.currentTarget.setPointerCapture(event.pointerId)
},
[fittedViewport, floorplanSceneRotationDeg, floorplanUserRotationDeg, viewport],
[
fittedViewport,
floorplanSceneRotationDeg,
floorplanUserRotationDeg,
viewport,
setFloorplanCursorPosition,
setCursorPoint,
],
)
const handlePointerDown = useCallback(
@@ -8570,7 +8570,7 @@ export function FloorplanPanel({
}
const wallNode = useScene.getState().nodes[wallId as AnyNodeId]
if (!wallNode || wallNode.type !== 'wall') {
if (wallNode?.type !== 'wall') {
return
}
@@ -8643,7 +8643,7 @@ export function FloorplanPanel({
const emitFloorplanCeilingLeave = useCallback((ceilingId: string | null) => {
if (!ceilingId) return
const ceilingNode = useScene.getState().nodes[ceilingId as AnyNodeId]
if (!ceilingNode || ceilingNode.type !== 'ceiling') return
if (ceilingNode?.type !== 'ceiling') return
emitter.emit('ceiling:leave', {
node: ceilingNode,
@@ -8834,8 +8834,7 @@ export function FloorplanPanel({
// chip on the right): `grid` quantizes via `snapToHalf` (whose step is
// 0 — i.e. off — in any non-grid mode), `angles` locks to 15° rays from
// the previous vertex, `lines` pulls onto wall corners / alignment
// guides, `off` is free. No Shift hold-to-bypass; Alt forces (skips
// alignment).
// guides, `off` is free. Alignment follows the magnetic snap mode.
const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive()
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
@@ -8846,7 +8845,6 @@ export function FloorplanPanel({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
@@ -8861,9 +8859,9 @@ export function FloorplanPanel({
// Roof is placed as a footprint (no directional draw → polygon context:
// grid / lines / off, no angle lock). Mode-driven, matching the chip:
// `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid
// modes), `lines` pulls onto alignment, `off` is free. Alt forces.
// modes), `lines` pulls onto alignment, `off` is free.
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
bypass: event.altKey || !isMagneticSnapActive(),
bypass: !isMagneticSnapActive(),
})
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
@@ -8930,7 +8928,7 @@ export function FloorplanPanel({
// Mode-driven (matches the chip): `grid` quantizes (`snapToHalf`'s step
// is 0 in non-grid modes), `angles` locks 15° rays from the previous
// vertex, `lines` snaps onto wall corners / alignment guides, `off` is
// free. No Shift bypass; Alt forces (skips alignment).
// free. Alignment follows the magnetic snap mode.
const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive()
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
@@ -8943,14 +8941,13 @@ export function FloorplanPanel({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
} else if (angleSnap) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
bypass: event.altKey || !isMagneticSnapActive(),
bypass: !isMagneticSnapActive(),
})
}
@@ -9025,9 +9022,8 @@ export function FloorplanPanel({
// builder needs a wall for `ctx.parent`, so we publish the opening on
// a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to
// `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it
// through the real `def.floorplan` builder. Shift bypasses grid snap.
const snappedPoint =
shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
// through the real `def.floorplan` builder.
const snappedPoint = getSnappedFloorplanPoint(planPoint)
showOpeningGhost(snappedPoint)
}
return
@@ -9039,8 +9035,7 @@ 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 bypassSnap = shiftPressed || event.shiftKey
const snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint)
const snappedPoint = getSnappedFloorplanPoint(planPoint)
setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
)
@@ -9060,7 +9055,7 @@ export function FloorplanPanel({
// this exclusion the catch-all would emit `grid:move` and re-drive the
// 3D MoveDoorTool's free-follow, fighting the overlay again.
if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
const snappedPoint = getSnappedFloorplanPoint(planPoint)
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
@@ -9172,12 +9167,15 @@ export function FloorplanPanel({
roofDraftStart,
elevatorResizeDragState,
siteVertexDragState,
shiftPressed,
surfaceSize.height,
surfaceSize.width,
viewBox.height,
viewBox.width,
walls,
setCursorPoint,
setDraftEnd,
setRoofDraftEnd,
setFenceDraftEnd,
],
)
@@ -9206,7 +9204,7 @@ export function FloorplanPanel({
setSlabDraftPoints((currentPoints) => [...currentPoints, point])
setCursorPoint(point)
},
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints],
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints, setCursorPoint],
)
const handleSlabPlacementConfirm = useCallback(
(point?: WallPlanPoint) => {
@@ -9256,7 +9254,7 @@ export function FloorplanPanel({
setCeilingDraftPoints((currentPoints) => [...currentPoints, point])
setCursorPoint(point)
},
[ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel],
[ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel, setCursorPoint],
)
const handleCeilingPlacementConfirm = useCallback(
(point?: WallPlanPoint) => {
@@ -9306,7 +9304,7 @@ export function FloorplanPanel({
setZoneDraftPoints((currentPoints) => [...currentPoints, point])
setCursorPoint(point)
},
[clearDraft, createZoneOnCurrentLevel, zoneDraftPoints],
[clearDraft, createZoneOnCurrentLevel, zoneDraftPoints, setCursorPoint],
)
const handleZonePlacementConfirm = useCallback(
(point?: WallPlanPoint) => {
@@ -9388,7 +9386,7 @@ export function FloorplanPanel({
setDraftEnd(nextStart)
setCursorPoint(nextStart)
},
[clearWallPlacementDraft, draftStart, wallChainFirstVertex],
[clearWallPlacementDraft, draftStart, wallChainFirstVertex, setDraftEnd, setCursorPoint],
)
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
ceilingPolygons: displayCeilingPolygons,
@@ -9604,6 +9602,7 @@ export function FloorplanPanel({
unit,
visibleZonePolygons,
emitFloorplanGridEvent,
setCursorPoint,
],
)
const handleSvgClick = useCallback(
@@ -9642,7 +9641,6 @@ export function FloorplanPanel({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
emitFloorplanGridEvent('double-click', snappedPoint, event)
@@ -9657,7 +9655,6 @@ export function FloorplanPanel({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
// Slab is registry-driven: forward the double-click so the 3D tool
@@ -10121,7 +10118,7 @@ export function FloorplanPanel({
})
setCursorPoint(toWallPlanPoint(vertexPoint))
},
[displaySitePolygon, setSiteBoundaryLivePreview],
[displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint],
)
const handleSiteVertexDoubleClick = useCallback(
(
@@ -10205,7 +10202,7 @@ export function FloorplanPanel({
})
setCursorPoint(insertedPoint)
},
[displaySitePolygon, setSiteBoundaryLivePreview],
[displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint],
)
const handlePointerLeave = useCallback(() => {
@@ -10224,7 +10221,7 @@ export function FloorplanPanel({
emitFloorplanWallLeave(hoveredWallIdRef.current)
hoveredWallIdRef.current = null
}
}, [emitFloorplanWallLeave, siteVertexDragState])
}, [emitFloorplanWallLeave, siteVertexDragState, setCursorPoint])
// Lightweight flag that mirrors the conditions under which
// FloorplanCursorIndicatorOverlay renders — used to gate cursor-position
@@ -10274,6 +10271,7 @@ export function FloorplanPanel({
isSpacePanPressed,
elevatorResizeDragState,
siteVertexDragState,
setFloorplanCursorPosition,
],
)
@@ -10281,7 +10279,7 @@ export function FloorplanPanel({
setFloorplanCursorPosition(null)
setHoveredGuideCorner(null)
handlePointerLeave()
}, [handlePointerLeave])
}, [handlePointerLeave, setFloorplanCursorPosition])
const handleMarqueePointerDown = useCallback(
(event: ReactPointerEvent<SVGRectElement>) => {
@@ -10317,7 +10315,12 @@ export function FloorplanPanel({
event.currentTarget.setPointerCapture(event.pointerId)
},
[getPlanPointFromClientPoint, syncPreviewSelectedIds],
[
getPlanPointFromClientPoint,
syncPreviewSelectedIds,
setFloorplanCursorPosition,
setCursorPoint,
],
)
const handleMarqueePointerMove = useCallback(
@@ -10370,7 +10373,13 @@ export function FloorplanPanel({
// marquee overlay leaf, never this panel.
useFloorplanMarquee.getState().setCurrent(snappedPoint)
},
[getFloorplanSelectionIdsInBounds, getPlanPointFromClientPoint, syncPreviewSelectedIds],
[
getFloorplanSelectionIdsInBounds,
getPlanPointFromClientPoint,
syncPreviewSelectedIds,
setFloorplanCursorPosition,
setCursorPoint,
],
)
const handleMarqueePointerUp = useCallback(
@@ -10441,7 +10450,7 @@ export function FloorplanPanel({
syncPreviewSelectedIds([])
setCursorPoint(null)
},
[syncPreviewSelectedIds],
[syncPreviewSelectedIds, setFloorplanCursorPosition, setCursorPoint],
)
useEffect(() => {
@@ -10456,7 +10465,13 @@ export function FloorplanPanel({
}
setFloorplanCursorPosition(null)
}, [isMarqueeSelectionToolActive, mode, syncPreviewSelectedIds])
}, [
isMarqueeSelectionToolActive,
mode,
syncPreviewSelectedIds,
setFloorplanCursorPosition,
setCursorPoint,
])
useEffect(() => {
if (mode !== 'delete') {
@@ -10554,7 +10569,7 @@ export function FloorplanPanel({
}
const buildingNode = sceneNodes[nextBuildingId]
if (!buildingNode || buildingNode.type !== 'building') {
if (buildingNode?.type !== 'building') {
return null
}
@@ -10793,7 +10808,7 @@ export function FloorplanPanel({
</form>
)}
{(!levelNode || levelNode.type !== 'level') && !hasAmbientBuildingLevel ? (
{levelNode?.type !== 'level' && !hasAmbientBuildingLevel ? (
<div className="flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
Switch to a building level to view and edit the floorplan.
</div>
@@ -99,6 +99,8 @@ export type HandleArrowProps = {
onPointerLeave?: PointerHandler
// Extrude the slimmer wall-handle chevron profile (chevron shape only).
thin?: boolean
// Render the corner-picker disc as a smooth circle instead of a hexagon.
round?: boolean
}
function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
@@ -336,7 +338,11 @@ export function createEndpointHitAreaGeometry(radius: number) {
return geometry
}
function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) {
// Hexagon (6 segments) by default; a smooth circle (32 segments) when `round`.
const CORNER_DISC_SEGMENTS = 6
const CORNER_DISC_ROUND_SEGMENTS = 32
function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round = false) {
if (shape === 'chevron') return createArrowHandleGeometry(thin)
if (shape === 'cross') return createMoveCrossHandleGeometry()
if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
@@ -345,17 +351,23 @@ function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) {
geometry.computeBoundingSphere()
return geometry
}
const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
const geometry = new CircleGeometry(
CORNER_HEX_RADIUS,
round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
)
geometry.computeBoundingSphere()
return geometry
}
function createHandleArrowHitGeometry(shape: HandleArrowShape) {
function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) {
if (shape === 'chevron') return createArrowHitAreaGeometry()
if (shape === 'cross') return createMoveCrossHitAreaGeometry()
if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry()
if (shape === 'tracker') return createTrackerHitAreaGeometry()
const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
const geometry = new CircleGeometry(
CORNER_HEX_RADIUS,
round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
)
geometry.computeBoundingSphere()
return geometry
}
@@ -482,10 +494,17 @@ export function HandleArrow({
onPointerEnter,
onPointerLeave,
thin = false,
round = false,
}: HandleArrowProps) {
const visualShape = normalizeHandleArrowShape(shape, cursor)
const geometry = useMemo(() => createHandleArrowGeometry(visualShape, thin), [visualShape, thin])
const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape])
const geometry = useMemo(
() => createHandleArrowGeometry(visualShape, thin, round),
[visualShape, thin, round],
)
const hitGeometry = useMemo(
() => createHandleArrowHitGeometry(visualShape, round),
[visualShape, round],
)
const indicatorMaterial = useHandleArrowMaterial(visualShape)
const hitMaterial = useInvisibleHitAreaMaterial()
const rootRef = useRef<Group>(null)
@@ -58,6 +58,7 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
import { CustomCameraControls } from './custom-camera-controls'
import { EditorLayoutV2 } from './editor-layout-v2'
import { ExportManager } from './export-manager'
import { FenceTangentLines3D } from './fence-tangent-lines-3d'
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
import { FloatingActionMenu } from './floating-action-menu'
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
@@ -725,6 +726,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
{!noEditing && <WallOpeningHighlights />}
{!noEditing && <SlabHoleHighlights />}
{!noEditing && <WallMoveSideHandles />}
{!noEditing && <FenceTangentLines3D />}
{!noEditing && <FloatingActionMenu />}
{!noEditing && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />}
@@ -1295,6 +1295,7 @@ function TapActionArrow({
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
const shape = descriptor.shape ?? 'arrow'
const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
const round = descriptor.round ?? false
const onActivate = useHandleDrag({
kind: 'tap',
@@ -1315,6 +1316,7 @@ function TapActionArrow({
onHoverChange={setIsHovered}
onPointerDown={onActivate}
position={position}
round={round}
/>
)
}
@@ -1420,6 +1422,7 @@ function CornerPickerShape({
hover,
onHoverChange,
onPointerDown,
round = false,
}: {
position: readonly [number, number, number]
height: number
@@ -1428,6 +1431,7 @@ function CornerPickerShape({
hover: boolean
onHoverChange: (hovered: boolean) => void
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
round?: boolean
}) {
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height])
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
@@ -1505,10 +1509,11 @@ function CornerPickerShape({
onHoverChange={onHoverChange}
onPointerDown={onPointerDown}
placement={{ position: [0, 0, 0], baseScale }}
round={round}
shape="corner-picker"
/>
<mesh material={ringMaterial} renderOrder={1002} scale={scale}>
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, round ? 32 : 6]} />
</mesh>
</group>
</>
@@ -57,11 +57,13 @@ import {
resolvePaintScopeTargets,
slotDisplayLabel,
} from '../../lib/paint-scope'
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
import {
resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick,
type SelectionModifierKeys,
selectionModifiersFromEvent,
shouldPreserveSelectedRoofHostTarget,
} from '../../lib/selection-routing'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
@@ -300,18 +302,21 @@ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode |
return bestSegment?.node ?? firstSegment
}
function isInActiveRoofContext(
segment: RoofSegmentNode,
selectedIds: readonly string[],
nodes: Record<string, AnyNode>,
): boolean {
if (!segment.parentId) return false
if (selectedIds.includes(segment.id) || selectedIds.includes(segment.parentId)) return true
function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode {
if (event.node.type === 'roof') {
if (
shouldPreserveSelectedRoofHostTarget({
node: event.node,
selectedIds: useViewer.getState().selection.selectedIds,
armedRoofId: useEditor.getState().roofHostDragArmedId,
})
) {
return event.node
}
return resolveRoofSegmentSelectionTarget(event) ?? event.node
}
return selectedIds.some((selectedId) => {
const selectedNode = nodes[selectedId]
return selectedNode?.type === 'roof-segment' && selectedNode.parentId === segment.parentId
})
return event.node
}
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
@@ -902,7 +907,7 @@ export const SelectionManager = () => {
: node.parentId
? useScene.getState().nodes[node.parentId as AnyNodeId]
: null
if (!roofNode || roofNode.type !== 'roof') return null
if (roofNode?.type !== 'roof') return null
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
const compatible = role !== null && paintEnabled
@@ -1428,7 +1433,7 @@ export const SelectionManager = () => {
const activeScope = useInteractionScope.getState().scope
if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return
const node = event.node
const node = resolveSelectModeNodeTarget(event)
// A ceiling is selectable only through its corner handles, never via
// the `ceiling-grid` body mesh. When the grid is revealed (ceiling
@@ -1481,18 +1486,6 @@ export const SelectionManager = () => {
}, 50)
let nodeToSelect = node
if (node.type === 'roof-segment' && node.parentId) {
const nodes = useScene.getState().nodes
const parentNode = nodes[node.parentId as AnyNodeId]
const selectedIds = useViewer.getState().selection.selectedIds
if (
parentNode &&
parentNode.type === 'roof' &&
!isInActiveRoofContext(node, selectedIds, nodes)
) {
nodeToSelect = parentNode
}
}
if (node.type === 'stair-segment' && node.parentId) {
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
if (parentNode && parentNode.type === 'stair') {
@@ -1670,7 +1663,7 @@ export const SelectionManager = () => {
// surface move tools keep tracking — but the select-hover outline must
// stay put, so don't repaint under the cursor mid-drag.
if (useViewer.getState().inputDragging) return
const node = event.node
const node = resolveSelectModeNodeTarget(event)
const currentPhase = useEditor.getState().phase
// Ignore site/building if we are already inside a building
@@ -1698,17 +1691,14 @@ export const SelectionManager = () => {
const onLeave = (event: NodeEvent) => {
if (useViewer.getState().inputDragging) return
const nodeId = event?.node?.id
const nodeId = resolveSelectModeNodeTarget(event)?.id
if (nodeId && useViewer.getState().hoveredId === nodeId) {
useViewer.setState({ hoveredId: null })
}
}
const onDoubleClick = (event: NodeEvent) => {
let node = event.node
if (node.type === 'roof') {
node = resolveRoofSegmentSelectionTarget(event) ?? node
}
let node = resolveSelectModeNodeTarget(event)
const currentPhase = useEditor.getState().phase
@@ -1893,6 +1883,8 @@ export const SelectionManager = () => {
const SelectionStateSync = () => {
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
const roofHostDragArmedId = useEditor((s) => s.roofHostDragArmedId)
const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId)
const singleSelectedId = useViewer((s) =>
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
)
@@ -1925,6 +1917,12 @@ const SelectionStateSync = () => {
})
}, [])
useEffect(() => {
if (!roofHostDragArmedId) return
if (singleSelectedId === roofHostDragArmedId) return
setRoofHostDragArmedId(null)
}, [roofHostDragArmedId, setRoofHostDragArmedId, singleSelectedId])
useEffect(() => {
if (!selectedMaterialTarget) return
@@ -2152,7 +2150,11 @@ const EditorOutlinerSync = () => {
if (!nodes[hoveredId as AnyNodeId]) {
useViewer.setState({ hoveredId: null })
} else {
const obj = sceneRegistry.nodes.get(hoveredId)
const hoveredNode = nodes[hoveredId as AnyNodeId]
const obj =
hoveredNode?.type === 'roof-segment'
? (getHoveredRoofSegmentOutlineProxy(hoveredId) ?? sceneRegistry.nodes.get(hoveredId))
: sceneRegistry.nodes.get(hoveredId)
if (obj?.parent) outliner.hoveredObjects.push(obj)
}
}
@@ -36,7 +36,7 @@ function useLevelChildren<TNode extends AnyNode>(
}
const levelNode = state.nodes[levelId]
if (!levelNode || levelNode.type !== 'level') {
if (levelNode?.type !== 'level') {
return [] as TNode[]
}
@@ -105,7 +105,7 @@ export function useFloorplanSceneData({
}
const buildingNode = state.nodes[currentBuildingId]
if (!buildingNode || buildingNode.type !== 'building') {
if (buildingNode?.type !== 'building') {
return [] as LevelNode[]
}
@@ -133,7 +133,7 @@ export function useFloorplanSceneData({
}
const nextLevelNode = state.nodes[levelId]
if (!nextLevelNode || nextLevelNode.type !== 'level') {
if (nextLevelNode?.type !== 'level') {
return [] as RoofNode[]
}
@@ -149,7 +149,7 @@ export function useFloorplanSceneData({
}
const nextLevelNode = state.nodes[levelId]
if (!nextLevelNode || nextLevelNode.type !== 'level') {
if (nextLevelNode?.type !== 'level') {
return [] as OpeningNode[]
}
@@ -171,7 +171,7 @@ export function useFloorplanSceneData({
}
const nextLevelNode = state.nodes[levelId]
if (!nextLevelNode || nextLevelNode.type !== 'level') {
if (nextLevelNode?.type !== 'level') {
return [] as AnyNode[]
}
@@ -33,8 +33,8 @@ import { EDITOR_LAYER } from '../../lib/constants'
* intersection → ✕ cross wall body (edge) → circle
*
* Subscribes to the shared `useWallSnapIndicator` store (published by the wall
* draft + endpoint-move tools). Shares the indigo accent of
* `Alignment3DGuideLayer` for visual consistency.
* draft + endpoint-move tools). The vertical mouse pillar and the corner
* (endpoint) square are green; the other floor glyphs are indigo.
*
* The point carries only XZ (building-local plan coords); like the alignment
* guides it's lifted to the active level's building-local Y each frame so it
@@ -43,6 +43,7 @@ import { EDITOR_LAYER } from '../../lib/constants'
*/
const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent
const MARKER_GREEN = 0x22_c5_5e // green-500 — vertical mouse marker + corner (endpoint) glyph
const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
const MARKER = 0.13 // world-meter base size of the floor glyph
@@ -66,6 +67,14 @@ const beaconMaterial = new MeshBasicNodeMaterial({
transparent: true,
opacity: 0.9,
})
const greenMarkerMaterial = new MeshBasicNodeMaterial({
color: MARKER_GREEN,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
opacity: 0.9,
})
const wallTopHighlightMaterial = new MeshBasicNodeMaterial({
color: BEACON_COLOR,
depthTest: false,
@@ -121,7 +130,7 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
<mesh
geometry={PILLAR_GEOMETRY}
layers={EDITOR_LAYER}
material={beaconMaterial}
material={greenMarkerMaterial}
position={[point.x, BEACON_HEIGHT / 2, point.z]}
renderOrder={1002}
/>
@@ -235,7 +244,7 @@ function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }
<mesh
geometry={FLAT_BOX_GEOMETRY}
layers={EDITOR_LAYER}
material={beaconMaterial}
material={greenMarkerMaterial}
position={[x, y, z]}
renderOrder={1001}
scale={[MARKER * 2, 1, MARKER * 2]}
File diff suppressed because it is too large Load Diff
@@ -9,11 +9,11 @@ import {
resolveAlignment,
useScene,
} from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useAlignmentGuides from '../../../store/use-alignment-guides'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import usePlacementPreview from '../../../store/use-placement-preview'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -1,6 +1,7 @@
import {
DEFAULT_ANGLE_STEP,
FenceNode,
getTwoPointFenceCurveTangents,
getWallCurveFrameAt,
getWallCurveLength,
isCurvedWall,
@@ -214,3 +215,44 @@ export function createFenceOnCurrentLevel(
return fence
}
/**
* Commit a smooth spline fence from a list of drawn control points. The
* centerline becomes a Catmull-Rom curve through `path`; `start`/`end` are
* pinned to the first/last point so endpoint handles, bbox, and miter
* references stay valid. Requires >= 2 points spanning a usable distance.
*/
export function createSplineFenceOnCurrentLevel(
path: FencePlanPoint[],
tangents = getTwoPointFenceCurveTangents(path),
): FenceNode | null {
const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState()
if (!currentLevelId || path.length < 2) {
return null
}
const start = path[0]!
const end = path[path.length - 1]!
// A degenerate single-point-ish path (all clicks on one spot) is rejected
// the same way a too-short straight segment is.
if (!isSegmentLongEnough(start, end) && path.length < 3) {
return null
}
const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
const defaults = useEditor.getState().toolDefaults.fence ?? {}
const fence = FenceNode.parse({
...defaults,
name: `Fence ${fenceCount + 1}`,
start,
end,
path,
tangents,
})
createNode(fence, currentLevelId)
sfxEmitter.emit('sfx:structure-build')
return fence
}
@@ -20,7 +20,6 @@ import {
type WallEvent,
type WallNode,
} from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
@@ -47,7 +46,11 @@ import {
import { EDITOR_LAYER } from '../../../lib/constants'
import { formatLinearMeasurement } from '../../../lib/measurements'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
import {
projectAlignmentGuidesWorldToActiveBuildingLocal,
resolveAlignmentForActiveBuilding,
} from '../../../lib/world-grid-snap'
import useAlignmentGuides from '../../../store/use-alignment-guides'
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
@@ -856,7 +859,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
alignX = ar.snap.dx
alignZ = ar.snap.dz
}
useAlignmentGuides.getState().set(ar.guides)
useAlignmentGuides
.getState()
.set(projectAlignmentGuidesWorldToActiveBuildingLocal(ar.guides))
} else {
useAlignmentGuides.getState().clear()
}
@@ -23,7 +23,6 @@ import {
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
@@ -32,6 +31,7 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useAlignmentGuides from '../../../store/use-alignment-guides'
import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { swallowNextClick } from '../../editor/node-arrow-handles'
@@ -1,4 +1,5 @@
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
collectAlignmentAnchors,
@@ -7,33 +8,93 @@ import {
type LevelNode,
RoofNode,
RoofSegmentNode,
resolveBuildingForLevel,
sceneRegistry,
snapScalar,
useScene,
type WallNode,
wallSegmentAnchors,
} from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { clearSurfacePlanSnapFeedback, resolveSurfacePlanPointSnap } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
import {
BufferGeometry,
DoubleSide,
Float32BufferAttribute,
type Group,
type Line,
Vector3,
} from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
resolveAlignmentForActiveBuilding,
snapWorldXZForActiveBuilding,
} from '../../../lib/world-grid-snap'
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const DEFAULT_WALL_HEIGHT = 0.5
const DEFAULT_PITCH_DEG = 40
const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function snapToActiveGrid(value: number): number {
return snapScalar(value, useEditor.getState().gridSnapStep)
// Walls that are direct children of a level.
function getLevelWalls(
levelId: string | null,
nodes: Readonly<Record<string, AnyNode>>,
): WallNode[] {
if (!levelId) return []
const levelNode = nodes[levelId]
if (levelNode?.type !== 'level') return []
return (levelNode as LevelNode).children
.map((childId) => nodes[childId])
.filter((node): node is WallNode => node?.type === 'wall')
}
// Walls on the level directly beneath the active one. Levels share the same
// local XZ origin (they only differ in world Y), so these walls live in the
// identical coordinate frame and feed straight into both the alignment pool
// and the magnetic wall-snap pipeline — letting a roof drawn on the upper
// floor snap onto the wall corners of the floor below.
function getBelowLevelWalls(
currentLevelId: string | null,
nodes: Readonly<Record<string, AnyNode>>,
): WallNode[] {
if (!currentLevelId) return []
const currentLevel = nodes[currentLevelId]
if (currentLevel?.type !== 'level') return []
const buildingId = resolveBuildingForLevel(currentLevel.id, nodes)
if (!buildingId) return []
const building = nodes[buildingId]
if (building?.type !== 'building') return []
const currentIndex = (currentLevel as LevelNode).level
const belowLevel = (building.children ?? [])
.map((childId) => nodes[childId])
.filter((node): node is LevelNode => node?.type === 'level' && node.level < currentIndex)
.sort((a, b) => b.level - a.level)[0]
return getLevelWalls(belowLevel?.id ?? null, nodes)
}
// Current-level + floor-below walls — the magnetic snap targets the roof draft
// locks onto (corners, midpoints, crossings, wall bodies), matching the wall
// tool. Same coordinate frame, so no transform is needed.
function getRoofSnapWalls(
currentLevelId: string | null,
nodes: Readonly<Record<string, AnyNode>>,
): WallNode[] {
return [...getLevelWalls(currentLevelId, nodes), ...getBelowLevelWalls(currentLevelId, nodes)]
}
// Current-level alignment anchors plus the floor-below wall corners.
function collectRoofAlignmentAnchors(
nodes: Readonly<Record<string, AnyNode>>,
currentLevelId: string | null,
): AlignmentAnchor[] {
return [
...collectAlignmentAnchors(nodes, '', currentLevelId),
...getBelowLevelWalls(currentLevelId, nodes).flatMap((wall) =>
wallSegmentAnchors(wall.id, wall.start, wall.end, wall.thickness),
),
]
}
/**
@@ -148,14 +209,168 @@ type PreviewState = {
levelY: number
}
function buildRoofGhostGeometry(
width: number,
depth: number,
wallHeight: number,
pitchDeg: number,
) {
const safeWidth = Math.max(width, 0.1)
const safeDepth = Math.max(depth, 0.1)
const halfWidth = safeWidth / 2
const halfDepth = safeDepth / 2
const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth
const vertices = [
// Front slope
-halfWidth,
wallHeight,
-halfDepth,
halfWidth,
wallHeight,
-halfDepth,
halfWidth,
ridgeHeight,
0,
-halfWidth,
wallHeight,
-halfDepth,
halfWidth,
ridgeHeight,
0,
-halfWidth,
ridgeHeight,
0,
// Back slope
-halfWidth,
ridgeHeight,
0,
halfWidth,
ridgeHeight,
0,
halfWidth,
wallHeight,
halfDepth,
-halfWidth,
ridgeHeight,
0,
halfWidth,
wallHeight,
halfDepth,
-halfWidth,
wallHeight,
halfDepth,
// Left gable
-halfWidth,
wallHeight,
-halfDepth,
-halfWidth,
ridgeHeight,
0,
-halfWidth,
wallHeight,
halfDepth,
// Right gable
halfWidth,
wallHeight,
-halfDepth,
halfWidth,
wallHeight,
halfDepth,
halfWidth,
ridgeHeight,
0,
]
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3))
geometry.computeVertexNormals()
return geometry
}
function buildRoofGhostEdges(width: number, depth: number, wallHeight: number, pitchDeg: number) {
const safeWidth = Math.max(width, 0.1)
const safeDepth = Math.max(depth, 0.1)
const halfWidth = safeWidth / 2
const halfDepth = safeDepth / 2
const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth
const vertices = [
// Base rectangle
-halfWidth,
wallHeight,
-halfDepth,
halfWidth,
wallHeight,
-halfDepth,
halfWidth,
wallHeight,
-halfDepth,
halfWidth,
wallHeight,
halfDepth,
halfWidth,
wallHeight,
halfDepth,
-halfWidth,
wallHeight,
halfDepth,
-halfWidth,
wallHeight,
halfDepth,
-halfWidth,
wallHeight,
-halfDepth,
// Ridge + gable edges
-halfWidth,
ridgeHeight,
0,
halfWidth,
ridgeHeight,
0,
-halfWidth,
wallHeight,
-halfDepth,
-halfWidth,
ridgeHeight,
0,
-halfWidth,
ridgeHeight,
0,
-halfWidth,
wallHeight,
halfDepth,
halfWidth,
wallHeight,
-halfDepth,
halfWidth,
ridgeHeight,
0,
halfWidth,
ridgeHeight,
0,
halfWidth,
wallHeight,
halfDepth,
]
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3))
return geometry
}
export const RoofTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const outlineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const setSelection = useViewer((state) => state.setSelection)
const setTool = useEditor((state) => state.setTool)
const setMode = useEditor((state) => state.setMode)
const selectedIdsRef = useRef(selectedIds)
useEffect(() => {
@@ -179,44 +394,38 @@ export const RoofTool: React.FC = () => {
outlineRef.current.geometry = new BufferGeometry()
// Alignment candidates — anchors of every alignable object; refreshed
// after each roof commits. Both corners of the rectangle align.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
// Snap the drafted corner onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid snap. Alignment runs
// only when the magnetic (lines) snapping mode is active.
const alignPoint = (
gridX: number,
gridZ: number,
rawX: number,
rawZ: number,
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
const ar = resolveAlignmentForActiveBuilding({
moving: [{ nodeId: '__roof-draft__', kind: 'corner', x: rawX, z: rawZ }],
// Alignment candidates — anchors of every alignable object on the active
// level plus the wall corners of the floor directly below, so a roof drawn
// on the upper floor aligns to the walls beneath it. Refreshed after each
// roof commits. Both corners of the rectangle align.
let alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId)
// Resolve a grid:move/click into the drafted corner via the shared surface
// snap pipeline: magnetic lock onto wall corners / midpoints / crossings /
// bodies on the active level + floor below (raising the green beacon),
// falling back to alignment guides, then to the world-grid snap. The same
// path the slab/ceiling tools use, so the beacon and coloring match. The
// pipeline reads the snapping mode itself (Shift bypass, magnetic on/off),
// so this tool never inspects the flags. `levelId` is intentionally omitted
// so the explicit floor-below `walls` aren't filtered back out.
const resolveDraftPoint = (event: GridEvent): [number, number] => {
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
const gridFallback: [number, number] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: rawPoint
const nodes = useScene.getState().nodes
return resolveSurfacePlanPointSnap({
rawPoint,
fallbackPoint: gridFallback,
walls: getRoofSnapWalls(currentLevelId, nodes),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
useAlignmentGuides.getState().set(ar.guides)
let x = gridX
let z = gridZ
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
movingId: '__roof-draft__',
highlightWalls: true,
}).point
}
const updateOutline = (
@@ -241,24 +450,7 @@ export const RoofTool: React.FC = () => {
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
// World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid. Snapping follows
// the global mode (grid quantize / lines alignment); Off keeps the raw
// cursor. Shift cycles the mode centrally — this tool never reads it.
const snapped: [number, number] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint(
snapped[0],
snapped[1],
event.localPosition[0],
event.localPosition[2],
!isMagneticSnapActive(),
)
const [gridX, gridZ] = resolveDraftPoint(event)
const y = event.localPosition[1]
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
@@ -291,23 +483,7 @@ export const RoofTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
// World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid. Snapping follows
// the global mode; Off keeps the raw cursor.
const snapped: [number, number] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint(
snapped[0],
snapped[1],
event.localPosition[0],
event.localPosition[2],
!isMagneticSnapActive(),
)
const [gridX, gridZ] = resolveDraftPoint(event)
const y = event.localPosition[1]
if (corner1Ref.current) {
@@ -322,8 +498,8 @@ export const RoofTool: React.FC = () => {
corner1Ref.current = null
outlineRef.current.visible = false
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
useAlignmentGuides.getState().clear()
alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId)
clearSurfacePlanSnapFeedback()
} else {
corner1Ref.current = [gridX, y, gridZ]
sfxEmitter.emit('sfx:structure-build-start')
@@ -341,7 +517,7 @@ export const RoofTool: React.FC = () => {
outlineRef.current.visible = false
setPreview((prev) => ({ ...prev, corner1: null }))
}
useAlignmentGuides.getState().clear()
clearSurfacePlanSnapFeedback()
}
emitter.on('grid:move', onGridMove)
@@ -352,7 +528,7 @@ export const RoofTool: React.FC = () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useAlignmentGuides.getState().clear()
clearSurfacePlanSnapFeedback()
corner1Ref.current = null
}
@@ -369,6 +545,34 @@ export const RoofTool: React.FC = () => {
return { length, width, centerX, centerZ }
}, [corner1, cursorPosition])
const roofGhostGeometry = useMemo(() => {
if (!previewDimensions) return null
return buildRoofGhostGeometry(
previewDimensions.length,
previewDimensions.width,
DEFAULT_WALL_HEIGHT,
DEFAULT_PITCH_DEG,
)
}, [previewDimensions])
const roofGhostEdges = useMemo(() => {
if (!previewDimensions) return null
return buildRoofGhostEdges(
previewDimensions.length,
previewDimensions.width,
DEFAULT_WALL_HEIGHT,
DEFAULT_PITCH_DEG,
)
}, [previewDimensions])
useEffect(
() => () => {
roofGhostGeometry?.dispose()
roofGhostEdges?.dispose()
},
[roofGhostEdges, roofGhostGeometry],
)
return (
<group>
<CursorSphere ref={cursorRef} />
@@ -402,21 +606,34 @@ export const RoofTool: React.FC = () => {
)}
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
<mesh
<group
layers={EDITOR_LAYER}
position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
rotation={[-Math.PI / 2, 0, 0]}
>
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
opacity={0.1}
side={DoubleSide}
transparent
/>
</mesh>
{roofGhostGeometry && (
<mesh geometry={roofGhostGeometry} layers={EDITOR_LAYER} renderOrder={1}>
<meshBasicMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
opacity={0.16}
side={DoubleSide}
transparent
/>
</mesh>
)}
{roofGhostEdges && (
<lineSegments geometry={roofGhostEdges} layers={EDITOR_LAYER} renderOrder={2}>
<lineBasicMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
opacity={0.5}
transparent
/>
</lineSegments>
)}
</group>
)}
</group>
)
@@ -50,7 +50,7 @@ export function collectSelectableCandidateIds(): string[] {
if (!levelId) return []
const levelNode = nodes[levelId as AnyNodeId] as LevelNode | undefined
if (!levelNode || levelNode.type !== 'level') return []
if (levelNode?.type !== 'level') return []
if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
@@ -6,6 +6,7 @@ import { furnishTools } from '../../../components/ui/action-menu/furnish-tools'
import { tools } from '../../../components/ui/action-menu/structure-tools'
import { EDITOR_LAYER } from '../../../lib/constants'
import useEditor from '../../../store/use-editor'
import useWallSnapIndicator from '../../../store/use-wall-snap-indicator'
interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
color?: string
@@ -40,6 +41,10 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
const mode = useEditor((s) => s.mode)
const catalogCategory = useEditor((s) => s.catalogCategory)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
// While a wall snap is active the beacon layer already marks the point
// (green square at a corner); hide the cursor's ground dot/ring so it
// doesn't sit on top of that glyph.
const isSnapping = useWallSnapIndicator((s) => s.point !== null)
// Find the icon for the current tool
let activeToolConfig = null
@@ -58,33 +63,35 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
{/* Flat marker on the ground. The bright center dot moves to the tip
of the line in `dotAtTip` mode (the placement point hangs above the
floor), leaving a faint ring here so the plan position stays read. */}
<group rotation={[-Math.PI / 2, 0, 0]}>
{/* Center dot — at the ground unless the placement point is elevated */}
{!dotAtTip && (
{!isSnapping && (
<group rotation={[-Math.PI / 2, 0, 0]}>
{/* Center dot — at the ground unless the placement point is elevated */}
{!dotAtTip && (
<mesh layers={EDITOR_LAYER} renderOrder={2}>
<circleGeometry args={[0.06, 32]} />
<meshBasicMaterial
color={color}
depthTest={false}
depthWrite={false}
opacity={0.9}
transparent
/>
</mesh>
)}
{/* Outer ring / glow */}
<mesh layers={EDITOR_LAYER} renderOrder={2}>
<circleGeometry args={[0.06, 32]} />
<circleGeometry args={[0.2, 32]} />
<meshBasicMaterial
color={color}
depthTest={false}
depthWrite={false}
opacity={0.9}
opacity={dotAtTip ? 0.2 : 0.25}
transparent
/>
</mesh>
)}
{/* Outer ring / glow */}
<mesh layers={EDITOR_LAYER} renderOrder={2}>
<circleGeometry args={[0.2, 32]} />
<meshBasicMaterial
color={color}
depthTest={false}
depthWrite={false}
opacity={dotAtTip ? 0.2 : 0.25}
transparent
/>
</mesh>
</group>
</group>
)}
{/* Vertical line */}
{height > 0 && (
@@ -6,6 +6,7 @@ export const DEFAULT_STAIR_STEP_COUNT = 10
export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
export const DEFAULT_STAIR_THICKNESS = 0.25
export const DEFAULT_STAIR_OPENING_OFFSET = 0
export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9
export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2
export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180
@@ -14,7 +14,6 @@ import {
syncAutoStairOpenings,
useScene,
} from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
@@ -23,6 +22,7 @@ import {
resolveStairDestinationLevel,
resolveStairPlacementLevelId,
} from '../../../lib/stair-levels'
import useAlignmentGuides from '../../../store/use-alignment-guides'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -38,6 +38,7 @@ import {
DEFAULT_STAIR_FILL_TO_FLOOR,
DEFAULT_STAIR_HEIGHT,
DEFAULT_STAIR_LENGTH,
DEFAULT_STAIR_OPENING_OFFSET,
DEFAULT_STAIR_RAILING_HEIGHT,
DEFAULT_STAIR_RAILING_MODE,
DEFAULT_STAIR_STEP_COUNT,
@@ -140,7 +141,7 @@ function createDefaultStairNode({
fromLevelId: levelId,
toLevelId: nextLevelId,
slabOpeningMode: 'destination',
openingOffset: 0.08,
openingOffset: DEFAULT_STAIR_OPENING_OFFSET,
width: DEFAULT_STAIR_WIDTH,
totalRise: DEFAULT_STAIR_HEIGHT,
stepCount: DEFAULT_STAIR_STEP_COUNT,
@@ -12,11 +12,13 @@ import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense, useMemo } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import {
useControlPointReshape,
useEditingHole,
useEndpointReshape,
useIsCurveReshape,
useMovingNode,
useReshapingNode,
useTangentReshape,
} from '../../store/use-interaction-scope'
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
@@ -68,6 +70,8 @@ export const ToolManager: React.FC = () => {
const movingNode = useMovingNode()
const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
const endpointReshape = useEndpointReshape()
const controlPointReshape = useControlPointReshape()
const tangentReshape = useTangentReshape()
const isCurveReshape = useIsCurveReshape()
const reshapingNode = useReshapingNode()
// The endpoint affordance tool's `target` is kind-specific
@@ -81,6 +85,18 @@ export const ToolManager: React.FC = () => {
? { fence: reshapingNode as FenceNode, endpoint: endpointReshape.endpoint }
: { wall: reshapingNode as WallNode, endpoint: endpointReshape.endpoint }
}, [endpointReshape, reshapingNode])
const controlPointTarget = useMemo(() => {
if (!(controlPointReshape && reshapingNode?.type === 'fence')) return null
return { fence: reshapingNode as FenceNode, index: controlPointReshape.index }
}, [controlPointReshape, reshapingNode])
const tangentTarget = useMemo(() => {
if (!(tangentReshape && reshapingNode?.type === 'fence')) return null
return {
fence: reshapingNode as FenceNode,
index: tangentReshape.index,
side: tangentReshape.side,
}
}, [reshapingNode, tangentReshape])
const editingHole = useEditingHole()
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
@@ -271,6 +287,24 @@ export const ToolManager: React.FC = () => {
</Suspense>
) : null
})()}
{controlPointTarget &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point')
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance target={controlPointTarget} />
</Suspense>
) : null
})()}
{tangentTarget &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent')
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance target={tangentTarget} />
</Suspense>
) : null
})()}
{showMover && movingNode.type !== 'building' && (
<MoveTool
onNodeMoved={handlePlacedNodeSelected}
@@ -14,6 +14,7 @@ import {
} from '../../../lib/snapping-mode'
import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
import useFenceCurveDraft from '../../../store/use-fence-curve-draft'
import { ShortcutToken } from '../primitives/shortcut-token'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
@@ -56,6 +57,7 @@ function ShortcutSequence({ keys }: { keys: string[] }) {
// `pointer-events-none`, so clickable chips opt back in.
function ChipRow({
ariaLabel,
disabled = false,
icon,
label,
onClick,
@@ -63,6 +65,7 @@ function ChipRow({
tooltip,
}: {
ariaLabel?: string
disabled?: boolean
icon?: string
label: string
onClick?: () => void
@@ -82,7 +85,9 @@ function ChipRow({
)
if (!onClick) {
return <div className={cn(ROW_CLASS, 'items-center')}>{body}</div>
return (
<div className={cn(ROW_CLASS, 'items-center', disabled && 'opacity-45 saturate-0')}>{body}</div>
)
}
const button = (
@@ -91,6 +96,7 @@ function ChipRow({
className={cn(
ROW_CLASS,
'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60',
disabled && 'opacity-45 saturate-0',
)}
onClick={onClick}
type="button"
@@ -181,6 +187,59 @@ function ContinuationChip({ context }: { context: ContinuationContext }) {
)
}
function FenceContinuationChips() {
const mode = useEditor((s) => s.getContinuation('fence'))
const setContinuation = useEditor((s) => s.setContinuation)
const curveStarted = useFenceCurveDraft((s) => s.pointCount > 0)
const isCurved = mode === 'curved'
const straightMode = isCurved ? 'continuous' : mode
const straightLabel = straightMode === 'single' ? 'Straight: Single' : 'Straight: Continuous'
const straightIcon = straightMode === 'single' ? 'lucide:minus' : 'lucide:waypoints'
const typeLabel = isCurved ? 'Type: Curved' : 'Type: Straight'
const typeIcon = isCurved ? 'lucide:spline' : 'lucide:minus'
return (
<>
<ChipRow
ariaLabel={`Fence type: ${isCurved ? 'Curved' : 'Straight'}`}
icon={typeIcon}
label={typeLabel}
onClick={() => setContinuation('fence', isCurved ? 'continuous' : 'curved')}
shortcut="T"
tooltip="Fence type — click or press T to switch between straight and curved"
/>
<ChipRow
ariaLabel={`Fence continuation: ${straightLabel}`}
disabled={isCurved}
icon={straightIcon}
label={straightLabel}
onClick={
isCurved
? undefined
: () => setContinuation('fence', straightMode === 'single' ? 'continuous' : 'single')
}
shortcut="C"
tooltip={
isCurved
? 'Straight continuation is unavailable while curved fence type is active'
: 'Straight fence continuation — click or press C to toggle'
}
/>
{/* Curved fences are committed by a closing gesture rather than per-click,
so the finish keys aren't discoverable on their own — surface them, but
only once the user has placed a point and a curve is actually in flight. */}
{isCurved && curveStarted ? (
<ChipRow
icon="lucide:circle-check"
label="Finish curve (or double-click)"
shortcut="Enter"
/>
) : null}
</>
)
}
const PAINT_SCOPE_ICONS: Record<PaintScope, string> = {
single: 'lucide:square',
object: 'lucide:box',
@@ -260,7 +319,10 @@ export function ContextualHelperPanel({
return (
<div className={CONTAINER_CLASS}>
{snapContext ? <SnappingChips context={snapContext} /> : null}
{continuationContext ? <ContinuationChip context={continuationContext} /> : null}
{continuationContext === 'fence' ? <FenceContinuationChips /> : null}
{continuationContext && continuationContext !== 'fence' ? (
<ContinuationChip context={continuationContext} />
) : null}
{showPaintScope ? <PaintScopeChip /> : null}
{hints.map((hint) => (
<div
@@ -38,6 +38,10 @@ function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] {
const action =
reshape === 'curve'
? 'Curve'
: reshape === 'control-point'
? 'Move control point'
: reshape === 'tangent'
? 'Move tangent'
: reshape === 'endpoint'
? 'Move endpoint'
: 'Move corner'
@@ -17,6 +17,7 @@ const DRAG_MARGIN = 8
// Pointer travel (px) below which a header press is treated as a click
// (toggles collapse) rather than a drag.
const CLICK_SLOP = 4
let desktopInspectorCollapsed = true
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), Math.max(min, max))
@@ -85,8 +86,21 @@ export function PanelWrapper({
const panelRef = useRef<HTMLDivElement>(null)
// The whole panel is collapsed to just its header by default; the chevron
// expands it to reveal the inspector body.
const [collapsed, setCollapsed] = useState(true)
// expands it to reveal the inspector body. Keep the desktop value shared
// across inspector swaps (roof ↔ segment, etc.) so navigating between
// related panels preserves whether the user left the inspector open.
const [collapsed, setCollapsedState] = useState(desktopInspectorCollapsed)
const setCollapsed = useCallback(
(next: boolean | ((previous: boolean) => boolean)) => {
setCollapsedState((previous) => {
const resolved = typeof next === 'function' ? next(previous) : next
desktopInspectorCollapsed = resolved
return resolved
})
},
[],
)
// Drag-to-reposition from the header. `offset` is a translation applied on
// top of the default `top-20 right-4` anchor; null until first dragged.
@@ -28,6 +28,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const setRoofHostDragArmedId = useEditor((state) => state.setRoofHostDragArmedId)
const { drag, dropTarget } = useTreeNodeDrag()
const segments = useScene(
@@ -57,8 +58,11 @@ export const RoofTreeNode = memo(function RoofTreeNode({
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
if (!e.metaKey && !e.ctrlKey && !e.shiftKey) {
setRoofHostDragArmedId(nodeId)
}
},
[nodeId, setSelection],
[nodeId, setRoofHostDragArmedId, setSelection],
)
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
@@ -157,6 +157,7 @@ const treeNodeByType: Record<
'turbine-vent': RegistryTreeNode,
cupola: RegistryTreeNode,
'eyebrow-vent': RegistryTreeNode,
skylight: RegistryTreeNode,
roof: RoofTreeNode,
stair: StairTreeNode,
door: DoorTreeNode,
@@ -86,7 +86,7 @@ export function useCeilingEvents() {
for (const id of ceilingIds) {
const node = nodes[id as AnyNodeId] as CeilingNode | undefined
if (!node || node.type !== 'ceiling' || node.polygon.length < 3) continue
if (node?.type !== 'ceiling' || node.polygon.length < 3) continue
if (resolveLevelId(node, nodes) !== activeLevelId) continue
const mesh = sceneRegistry.nodes.get(id)
if (!mesh) continue
+30
View File
@@ -92,6 +92,26 @@ export const useKeyboard = ({
return
}
if (
(e.key === 't' || e.key === 'T') &&
!e.repeat &&
!e.metaKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.altKey
) {
const context = getActiveContinuationContext()
if (context === 'fence') {
e.preventDefault()
const current = useEditor.getState().getContinuation('fence')
useEditor
.getState()
.setContinuation('fence', current === 'curved' ? 'continuous' : 'curved')
sfxEmitter.emit('sfx:grid-snap')
return
}
}
if (
(e.key === 'c' || e.key === 'C') &&
!e.repeat &&
@@ -103,6 +123,16 @@ export const useKeyboard = ({
const context = getActiveContinuationContext()
if (context) {
e.preventDefault()
if (context === 'fence') {
const current = useEditor.getState().getContinuation('fence')
if (current !== 'curved') {
useEditor
.getState()
.setContinuation('fence', current === 'single' ? 'continuous' : 'single')
sfxEmitter.emit('sfx:grid-snap')
}
return
}
useEditor.getState().cycleContinuation(context)
sfxEmitter.emit('sfx:grid-snap')
return
+4
View File
@@ -80,6 +80,7 @@ export {
// shared/segment-angle.ts) once every Stage D port is in.
export {
createFenceOnCurrentLevel,
createSplineFenceOnCurrentLevel,
type FencePlanPoint,
snapFenceDraftPoint,
} from './components/tools/fence/fence-drafting'
@@ -353,7 +354,9 @@ export {
export { cn } from './lib/utils'
export {
getActiveBuildingPose,
projectAlignmentGuidesWorldToActiveBuildingLocal,
resolveAlignmentForActiveBuilding,
resolveAlignmentForFloorplanView,
snapBuildingLocalToWorldGrid,
snapWorldXZForActiveBuilding,
} from './lib/world-grid-snap'
@@ -377,6 +380,7 @@ export {
isMagneticSnapActive,
} from './store/use-editor'
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
export {
default as useInteractionScope,
getEditingHole,
+11 -3
View File
@@ -17,10 +17,18 @@ export const CONTINUATION_PROFILES: Record<
icons: { room: 'lucide:square', single: 'lucide:minus' },
},
fence: {
options: ['continuous', 'single'],
options: ['single', 'continuous', 'curved'],
default: 'continuous',
labels: { continuous: 'Continuous', single: 'Single fence' },
icons: { continuous: 'lucide:waypoints', single: 'lucide:minus' },
labels: {
continuous: 'Continuous',
single: 'Single fence',
curved: 'Curved fence',
},
icons: {
continuous: 'lucide:waypoints',
single: 'lucide:minus',
curved: 'lucide:spline',
},
},
point: {
options: ['once', 'repeat'],
@@ -71,6 +71,19 @@ describe('canDirectMoveNode', () => {
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
})
test('rejects MEP kinds that own move through bespoke selection affordances', () => {
for (const kind of [
'duct-segment',
'duct-fitting',
'pipe-segment',
'pipe-fitting',
'lineset',
'liquid-line',
]) {
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
}
})
test('accepts kinds with a bespoke move tool', () => {
const kind = 'direct-move-bespoke-tool-test'
registerTestDefinition(kind, {
@@ -34,7 +34,22 @@ export function canDirectRotateNode(node: AnyNode): boolean {
)
}
const BESPOKE_SELECTION_MOVE_KINDS = new Set([
'duct-segment',
'duct-fitting',
'pipe-segment',
'pipe-fitting',
'lineset',
'liquid-line',
])
export function canDirectMoveNode(node: AnyNode): boolean {
// These MEP kinds own move through bespoke selection rigs (latch cubes,
// directional arrows, grid-driven previews). Sending body drags/clicks
// through the generic direct-move handoff conflicts with that path and can
// leave the editor appearing frozen while their mover waits for the wrong
// gesture stream.
if (BESPOKE_SELECTION_MOVE_KINDS.has(node.type)) return false
// 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
// mounts in 3D — distinct from `isRegistryMovable`, which also accepts
// floorplan-only movers (zone) for the 2D plan.
+11 -1
View File
@@ -1,7 +1,11 @@
import type { AnyNode, EditorApi } from '@pascal-app/core'
import useEditor from '../store/use-editor'
import useInteractionScope from '../store/use-interaction-scope'
import { endpointReshapeScope } from './interaction/scope'
import {
controlPointReshapeScope,
endpointReshapeScope,
tangentReshapeScope,
} from './interaction/scope'
/**
* Concrete {@link EditorApi} backed by `useEditor` + the interaction scope.
@@ -36,5 +40,11 @@ export function createEditorApi(): EditorApi {
// endpoint-draggable kind needs no entry here.
useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint))
},
engageControlPointMove(node: AnyNode, index: number) {
useInteractionScope.getState().begin(controlPointReshapeScope(node.id, index))
},
engageTangentMove(node: AnyNode, index: number, side: 'in' | 'out') {
useInteractionScope.getState().begin(tangentReshapeScope(node.id, index, side))
},
}
}
@@ -2,10 +2,10 @@ import {
type AlignmentAnchor,
type AlignmentGuide,
collectAlignmentAnchors,
resolveAlignment,
useScene,
} from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import useAlignmentGuides from '../../store/use-alignment-guides'
import { resolveAlignmentForFloorplanView } from '../world-grid-snap'
/**
* Fixed Figma-style alignment threshold (meters) for floor-plan placement /
@@ -49,7 +49,7 @@ export function applyFloorplanAlignment(
return { point: [point[0], point[1]], snapped: false, guides: [] }
}
const result = resolveAlignment({
const result = resolveAlignmentForFloorplanView({
moving: movingAnchors,
candidates,
threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M,
+1 -1
View File
@@ -570,7 +570,7 @@ function bakeSwingDoorClip(
doorObject.traverse((object) => {
const marker = object.userData.pascalSwingLeaf as SwingLeafMarker | undefined
if (!marker || marker.axis !== 'y') return
if (marker?.axis !== 'y') return
object.rotation.y = 0
const closed = object.quaternion.clone()
+36 -1
View File
@@ -18,7 +18,7 @@ export type InteractionView = '2d' | '3d'
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
// (rather than four sibling scopes) keeps the union small while still making
// "curving and hole-editing at once" unrepresentable.
export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary'
export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' | 'control-point' | 'tangent'
export type InteractionScope =
| { kind: 'idle' }
@@ -49,6 +49,8 @@ export type InteractionScope =
reshape: ReshapeKind
holeIndex?: number
endpoint?: 'start' | 'end'
index?: number
side?: 'in' | 'out'
}
// Marquee selection drag.
| { kind: 'box-select' }
@@ -151,6 +153,27 @@ export function endpointReshapeInfo(
: null
}
export function controlPointReshapeInfo(
scope: InteractionScope,
): { nodeId: string; index: number } | null {
return scope.kind === 'reshaping' &&
scope.reshape === 'control-point' &&
scope.index !== undefined
? { nodeId: scope.nodeId, index: scope.index }
: null
}
export function tangentReshapeInfo(
scope: InteractionScope,
): { nodeId: string; index: number; side: 'in' | 'out' } | null {
return scope.kind === 'reshaping' &&
scope.reshape === 'tangent' &&
scope.index !== undefined &&
scope.side !== undefined
? { nodeId: scope.nodeId, index: scope.index, side: scope.side }
: null
}
// The id of the node being reshaped (any reshape kind), for the scene lookup
// that recovers the full node payload a few consumers still need.
export function reshapingNodeId(scope: InteractionScope): string | null {
@@ -169,6 +192,18 @@ export function endpointReshapeScope(
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
}
export function controlPointReshapeScope(nodeId: string, index: number): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'control-point', index }
}
export function tangentReshapeScope(
nodeId: string,
index: number,
side: 'in' | 'out',
): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side }
}
// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope {
+2 -2
View File
@@ -5,10 +5,10 @@ import { useViewer } from '@pascal-app/viewer'
function getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null {
const { nodes } = useScene.getState()
const level = nodes[levelId]
if (!level || level.type !== 'level' || !level.parentId) return null
if (level?.type !== 'level' || !level.parentId) return null
const building = nodes[level.parentId as AnyNodeId]
if (!building || building.type !== 'building') return null
if (building?.type !== 'building') return null
const siblingLevelIds = (building as BuildingNode).children.filter(
(childId): childId is LevelNode['id'] => nodes[childId as AnyNodeId]?.type === 'level',
+6 -6
View File
@@ -80,7 +80,7 @@ export function duplicateRoofSubtree(
const scene = useScene.getState()
const sourceRoof = scene.nodes[sourceRoofId]
if (!sourceRoof || sourceRoof.type !== 'roof') {
if (sourceRoof?.type !== 'roof') {
throw new Error(`Node "${sourceRoofId}" is not a roof`)
}
@@ -105,7 +105,7 @@ export function duplicateRoofSubtree(
const segmentClones: RoofSegmentNode[] = []
for (const childId of sourceRoof.children ?? []) {
const childNode = scene.nodes[childId as AnyNodeId]
if (!childNode || childNode.type !== 'roof-segment') {
if (childNode?.type !== 'roof-segment') {
continue
}
@@ -125,7 +125,7 @@ export function duplicateRoofSubtree(
const nextScene = useScene.getState()
const createdRoof = nextScene.nodes[roofClone.id as AnyNodeId]
if (!createdRoof || createdRoof.type !== 'roof') {
if (createdRoof?.type !== 'roof') {
throw new Error(`Duplicated roof "${roofClone.id}" was not created`)
}
@@ -151,7 +151,7 @@ export function duplicateRoofSubtree(
const invalidSegment = segmentIds.find((segmentId) => {
const segment = nextScene.nodes[segmentId as AnyNodeId]
return !segment || segment.type !== 'roof-segment' || segment.parentId !== createdRoof.id
return segment?.type !== 'roof-segment' || segment.parentId !== createdRoof.id
})
if (invalidSegment) {
throw new Error(
@@ -179,7 +179,7 @@ export function clearRoofDuplicateMetadata(
) {
const scene = useScene.getState()
const roofNode = scene.nodes[roofId]
if (!roofNode || roofNode.type !== 'roof') {
if (roofNode?.type !== 'roof') {
return
}
@@ -198,7 +198,7 @@ export function clearRoofDuplicateMetadata(
for (const childId of roofNode.children ?? []) {
const childNode = scene.nodes[childId as AnyNodeId]
if (!childNode || childNode.type !== 'roof-segment') {
if (childNode?.type !== 'roof-segment') {
continue
}
@@ -0,0 +1,22 @@
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import type * as THREE from 'three'
export const HOVERED_ROOF_SEGMENT_OUTLINE_PROXY_NAME = '__roof-hover-outline-proxy__'
function hoveredRoofSegmentOutlineProxyName(segmentId: string) {
return `${HOVERED_ROOF_SEGMENT_OUTLINE_PROXY_NAME}:${segmentId}`
}
export function getHoveredRoofSegmentOutlineProxy(segmentId: string): THREE.Object3D | null {
const segment = useScene.getState().nodes[segmentId as AnyNodeId]
if (!(segment?.type === 'roof-segment' && segment.parentId)) return null
return (
sceneRegistry.nodes
.get(segment.parentId)
?.getObjectByName(hoveredRoofSegmentOutlineProxyName(segmentId)) ?? null
)
}
export function getHoveredRoofSegmentOutlineProxyName(segmentId: string): string {
return hoveredRoofSegmentOutlineProxyName(segmentId)
}
+1 -1
View File
@@ -114,7 +114,7 @@ function getNextLevelId(level: LevelNode, nodes: Record<AnyNodeId, AnyNode>) {
if (!parentId) return null
const building = nodes[parentId]
if (!building || building.type !== 'building') return null
if (building?.type !== 'building') return null
const siblingLevels = building.children
.map((childId) => nodes[childId as AnyNodeId])
@@ -4,6 +4,7 @@ import {
resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick,
selectionModifiersFromEvent,
shouldPreserveSelectedRoofHostTarget,
} from './selection-routing'
describe('resolveSelectedIdsForNodeClick', () => {
@@ -77,3 +78,41 @@ describe('resolveNodeSelectionTarget', () => {
})
})
})
describe('shouldPreserveSelectedRoofHostTarget', () => {
test('keeps the roof host target while that roof is the sole armed selection', () => {
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
expect(
shouldPreserveSelectedRoofHostTarget({
node,
selectedIds: ['roof_1'],
armedRoofId: 'roof_1',
}),
).toBe(true)
})
test('falls back to segment targeting when the roof host is not armed', () => {
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
expect(
shouldPreserveSelectedRoofHostTarget({
node,
selectedIds: ['roof_1'],
armedRoofId: null,
}),
).toBe(false)
})
test('falls back to segment targeting when the roof is no longer the sole selection', () => {
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
expect(
shouldPreserveSelectedRoofHostTarget({
node,
selectedIds: ['roof_1', 'wall_1'],
armedRoofId: 'roof_1',
}),
).toBe(false)
})
})
@@ -66,6 +66,23 @@ export function resolveSelectedIdsForNodeClick({
return [nodeId]
}
export function shouldPreserveSelectedRoofHostTarget({
node,
selectedIds,
armedRoofId,
}: {
node: AnyNode
selectedIds: readonly string[]
armedRoofId: string | null
}): boolean {
return (
node.type === 'roof' &&
armedRoofId === node.id &&
selectedIds.length === 1 &&
selectedIds[0] === node.id
)
}
export function resolveNodeSelectionTarget(node: AnyNode): NodeSelectionTarget | null {
if (node.type === 'building') {
return { phase: 'site' }
@@ -119,7 +119,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
// Roof / stair / elevator are placed as footprints, not directional draws →
// declared `snapDraftDirectional: false`, so their draft context drops the
// angle-lock mode. Directional structural kinds (no flag) stay `wall`.
const draftDirectionalOf = (t: string) => (t === 'roof' ? false : true)
const draftDirectionalOf = (t: string) => t !== 'roof'
const draftCtx = (tool: string) =>
snapContextOf({ scope: { kind: 'idle' }, mode: 'build', tool, profileOf, draftDirectionalOf })
expect(draftCtx('roof')).toBe('polygon')
+4 -4
View File
@@ -37,7 +37,7 @@ function stripDuplicateFlags(metadata: unknown) {
function moveStairWhenRegistered(stairId: StairNode['id'], attempt = 0) {
const latestStair = useScene.getState().nodes[stairId as AnyNodeId]
if (!latestStair || latestStair.type !== 'stair') {
if (latestStair?.type !== 'stair') {
return
}
@@ -64,7 +64,7 @@ export function duplicateStairSubtree(
const scene = useScene.getState()
const sourceStair = scene.nodes[sourceStairId]
if (!sourceStair || sourceStair.type !== 'stair') {
if (sourceStair?.type !== 'stair') {
throw new Error(`Node "${sourceStairId}" is not a stair`)
}
@@ -89,7 +89,7 @@ export function duplicateStairSubtree(
const segmentClones: StairSegmentNode[] = []
for (const childId of sourceStair.children ?? []) {
const childNode = scene.nodes[childId as AnyNodeId]
if (!childNode || childNode.type !== 'stair-segment') {
if (childNode?.type !== 'stair-segment') {
continue
}
@@ -108,7 +108,7 @@ export function duplicateStairSubtree(
])
const createdStair = useScene.getState().nodes[stairClone.id as AnyNodeId]
if (!createdStair || createdStair.type !== 'stair') {
if (createdStair?.type !== 'stair') {
throw new Error(`Duplicated stair "${stairClone.id}" was not created`)
}
+3 -9
View File
@@ -6,7 +6,6 @@ import {
getWallCurveFrameAt,
getWallCurveLength,
isCurvedWall,
resolveAlignment,
resolveLevelId,
useScene,
type WallNode,
@@ -21,6 +20,7 @@ import {
import useAlignmentGuides from '../store/use-alignment-guides'
import { isMagneticSnapActive } from '../store/use-editor'
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
import { resolveAlignmentForFloorplanView } from './world-grid-snap'
const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
export const SURFACE_ALIGNMENT_THRESHOLD_M = 0.08
@@ -172,12 +172,6 @@ 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
@@ -213,7 +207,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
// only when magnetic snap is on — `grid`/`angles`/`off` keep the grid/raw
// `fallbackPoint` instead of being pulled onto an alignment axis.
const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || input.altKey || !magnetic) {
if (input.align === false || !magnetic) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
@@ -228,7 +222,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const alignment = resolveAlignment({
const alignment = resolveAlignmentForFloorplanView({
moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }],
candidates,
threshold: input.threshold ?? SURFACE_ALIGNMENT_THRESHOLD_M,
+35 -3
View File
@@ -10,6 +10,7 @@
*/
import {
type AlignmentAnchor,
type AlignmentGuide,
type AnyNodeId,
type BuildingPose,
type ResolveAlignmentInBuildingResult,
@@ -54,7 +55,7 @@ export function getActiveBuildingPose(): BuildingPose | null {
}
if (!buildingId) buildingId = sel.buildingId ?? null
const building = buildingId ? nodes[buildingId] : null
if (!building || building.type !== 'building') return null
if (building?.type !== 'building') return null
const live = useLiveTransforms.getState().transforms.get(buildingId as string)
return {
position: live?.position ?? building.position,
@@ -80,6 +81,37 @@ export function resolveAlignmentForActiveBuilding(args: {
return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() })
}
function worldXZToPoseLocal(x: number, z: number, pose: BuildingPose | null): [number, number] {
if (!pose) return [x, z]
const cos = Math.cos(pose.rotationY)
const sin = Math.sin(pose.rotationY)
const dx = x - pose.position[0]
const dz = z - pose.position[2]
return [dx * cos - dz * sin, dx * sin + dz * cos]
}
/**
* Project WORLD-frame alignment guides into the active building's LOCAL frame.
*
* The 3D alignment layer is still mounted inside the building-local tool group,
* so tools that resolve alignment on the world axes (item placement, slab move)
* need their guides converted before publishing to `useAlignmentGuides`.
*/
export function projectAlignmentGuidesWorldToActiveBuildingLocal(
guides: readonly AlignmentGuide[],
): AlignmentGuide[] {
const pose = getActiveBuildingPose()
return guides.map((guide) => {
const [fromX, fromZ] = worldXZToPoseLocal(guide.from.x, guide.from.z, pose)
const [toX, toZ] = worldXZToPoseLocal(guide.to.x, guide.to.z, pose)
return {
...guide,
from: { x: fromX, z: fromZ },
to: { x: toX, z: toZ },
}
})
}
/**
* Baseline rotation the floor-plan view applies on top of the building
* rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 90` in floorplan-panel.tsx —
@@ -159,7 +191,7 @@ export function snapWorldXZForActiveBuilding(
): { world: [number, number]; local: [number, number] } {
const buildingId = useViewer.getState().selection.buildingId
const building = buildingId ? useScene.getState().nodes[buildingId] : null
if (!building || building.type !== 'building') {
if (building?.type !== 'building') {
if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] }
const sx = Math.round(worldX / step) * step
const sz = Math.round(worldZ / step) * step
@@ -186,7 +218,7 @@ export function snapBuildingLocalToWorldGrid(
): [number, number] {
const buildingId = useViewer.getState().selection.buildingId
const building = buildingId ? useScene.getState().nodes[buildingId] : null
if (!building || building.type !== 'building') {
if (building?.type !== 'building') {
if (step <= 0) return [local[0], local[1]]
return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step]
}
+4
View File
@@ -239,6 +239,8 @@ type EditorState = {
*/
placementDragMode: boolean
setPlacementDragMode: (dragMode: boolean) => void
roofHostDragArmedId: AnyNodeId | null
setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void
setMovingNode: (
node:
| ItemNode
@@ -870,6 +872,8 @@ const useEditor = create<EditorState>()(
setSelectedItem: (item) => set({ selectedItem: item }),
placementDragMode: false,
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
roofHostDragArmedId: null,
setRoofHostDragArmedId: (nodeId) => set({ roofHostDragArmedId: nodeId }),
// The node being placed/moved now lives inside the interaction scope
// (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter
// remains the single entry point: it drives the scope and still touches
@@ -0,0 +1,21 @@
// Ephemeral store: how many points the in-progress curved-fence draft has
// placed. Written by the 3D spline draft tool (`@pascal-app/nodes`
// fence/tool.tsx) and read by the contextual helper so the "finish curve" hint
// only surfaces once the user has actually started drawing. Reset on commit,
// cancel, and unmount — never persisted, never in undo history.
import { create } from 'zustand'
type FenceCurveDraftState = {
pointCount: number
setPointCount(count: number): void
reset(): void
}
const useFenceCurveDraft = create<FenceCurveDraftState>((set) => ({
pointCount: 0,
setPointCount: (count) => set({ pointCount: count }),
reset: () => set({ pointCount: 0 }),
}))
export default useFenceCurveDraft
@@ -6,6 +6,7 @@ import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
import {
type ActiveInteractionScope,
controlPointReshapeInfo,
editingHoleInfo,
endpointReshapeInfo,
handleDragInfo,
@@ -14,6 +15,7 @@ import {
isCurveReshape,
movingNodeOf,
reshapingNodeId,
tangentReshapeInfo,
} from '../lib/interaction/scope'
// The authoritative interaction state machine. A single owner holds exactly one
@@ -87,6 +89,12 @@ export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCur
export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null =>
useInteractionScope(useShallow((s) => endpointReshapeInfo(s.scope)))
export const useControlPointReshape = (): { nodeId: string; index: number } | null =>
useInteractionScope(useShallow((s) => controlPointReshapeInfo(s.scope)))
export const useTangentReshape = (): { nodeId: string; index: number; side: 'in' | 'out' } | null =>
useInteractionScope(useShallow((s) => tangentReshapeInfo(s.scope)))
// The node currently being reshaped (curve / endpoint / hole), looked up live
// from the scene by the scope's `nodeId`. During a reshape the scene node holds
// the same data the legacy `curvingWall` / `movingWallEndpoint.wall` carried, so