editor: improve floorplan modes and annotations (#549)
* 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(editor): anchor floorplan cursor to snapped point * fix(nodes): preview floorplan edits through live overrides * feat(editor): add context-aware floorplan modes * fix(nodes): render crisp wall selection hatching * fix(editor): cap floorplan handles at extreme zoom * fix(editor): keep zone labels upright after rotation * refactor(editor): make referenced annotations registry-driven * refactor(nodes): colocate contextual dimension builders * fix(editor): use mode-driven angle snapping * fix(nodes): use mode-driven move snapping * chore(editor): update react scan tooling * refactor(floorplan): streamline construction documentation * refactor(floorplan): remove wall assembly roadmap * fix(floorplan): migrate retired scene data --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
daa1f3e99b
commit
ab76686b8b
@@ -2,8 +2,10 @@ import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type ShelfNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
// Mirror the 3D handles in `shelf/definition.ts` so a drag can't push a
|
||||
@@ -45,13 +47,15 @@ export const shelfResizeAffordance: FloorplanAffordance<ShelfNode> = {
|
||||
} else {
|
||||
lastPatch = { depth: Math.max(MIN_SHELF_DEPTH, initialDepth + 2 * projDelta) }
|
||||
}
|
||||
useScene.getState().updateNode(shelfId, lastPatch)
|
||||
useLiveNodeOverrides.getState().set(shelfId, lastPatch)
|
||||
useScene.getState().markDirty(shelfId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
if (Object.keys(lastPatch).length > 0) {
|
||||
useLiveNodeOverrides.getState().clear(shelfId)
|
||||
useScene.getState().updateNode(shelfId, lastPatch)
|
||||
}
|
||||
},
|
||||
@@ -83,21 +87,23 @@ export const shelfRotateAffordance: FloorplanAffordance<ShelfNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [shelfId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
const newRotationY = initialRotationY - delta
|
||||
lastRotation = [r[0], newRotationY, r[2]]
|
||||
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
||||
useLiveNodeOverrides.getState().set(shelfId, { rotation: lastRotation })
|
||||
useScene.getState().markDirty(shelfId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(shelfId)
|
||||
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@ import {
|
||||
type FloorplanMoveTargetSession,
|
||||
movingFootprintAnchors,
|
||||
type ShelfNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
getFloorStackPreviewPosition,
|
||||
getSegmentGridStep,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
@@ -23,22 +25,8 @@ import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
* because shelf is a `position`-field kind (it carries its location in
|
||||
* `node.position`, not in polygon vertices):
|
||||
*
|
||||
* - Each pointermove writes the absolute world-plan position straight
|
||||
* to `useScene` (history is paused by the overlay). This is the single
|
||||
* source of truth: the 2D `FloorplanRegistryLayer` and the 3D
|
||||
* `ParametricNodeRenderer` group transform both follow it reactively,
|
||||
* so 2D and 3D can never diverge.
|
||||
* - On commit, the overlay's snapshot-diff reverts to baseline, resumes
|
||||
* history, and re-applies the final position as one undoable step.
|
||||
* `canCommit` only validates.
|
||||
*
|
||||
* Earlier this used the `useLiveTransforms` + imperative-mesh pattern that
|
||||
* `slab` / `ceiling` use. That works for polygon kinds because their commit
|
||||
* rebuilds geometry (the vertices change), which forces the 3D group to
|
||||
* reconcile. Shelf's `geometryKey` excludes `position`, so its commit
|
||||
* `markDirty` is a no-op and nothing reconciled the 3D group off the cleared
|
||||
* live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the
|
||||
* scene directly removes that second source of truth entirely.
|
||||
* - Each pointermove previews through `useLiveNodeOverrides`.
|
||||
* - On commit, the final position is written once as one undoable step.
|
||||
*/
|
||||
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node, nodes }) => {
|
||||
const shelfId = node.id as AnyNodeId
|
||||
@@ -49,6 +37,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
metadata: node.metadata,
|
||||
})
|
||||
let lastPosition: [number, number, number] = originalPosition
|
||||
let lastVisualPosition: [number, number, number] = originalPosition
|
||||
let lastSnapKey: string | null = null
|
||||
|
||||
// Alignment candidates — corner/edge/segment anchors of every OTHER node
|
||||
@@ -58,16 +47,13 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [shelfId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snap = (value: number) => {
|
||||
if (modifiers.shiftKey) return value
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
apply({ planPoint }) {
|
||||
const gridSnapActive = isGridSnapActive()
|
||||
const step = gridSnapActive ? getSegmentGridStep() : 0
|
||||
const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value)
|
||||
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
|
||||
// Figma-style alignment layered on the grid snap — the shelf footprint
|
||||
// edges snap to neighbours / wall faces and a guide is published. Alt
|
||||
// bypasses alignment; Shift bypasses all snap.
|
||||
// edges snap to neighbours / wall faces and a guide is published.
|
||||
const { point: snapped } = applyFloorplanAlignment(
|
||||
gridSnapped,
|
||||
movingFootprintAnchors(
|
||||
@@ -77,7 +63,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
originalRotationY,
|
||||
),
|
||||
candidates,
|
||||
{ applySnap: isMagneticSnapActive(), bypass: modifiers.altKey || modifiers.shiftKey },
|
||||
{ applySnap: isMagneticSnapActive() },
|
||||
)
|
||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||
lastPosition = next
|
||||
@@ -86,7 +72,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
// and the placement coordinators. Item / slab / wall flows fire
|
||||
// the same cue, so the shelf following along is the expected UX.
|
||||
const snapKey = `${snapped[0]},${snapped[1]}`
|
||||
if (!modifiers.shiftKey && snapKey !== lastSnapKey) {
|
||||
if (gridSnapActive && snapKey !== lastSnapKey) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
}
|
||||
@@ -96,23 +82,19 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
rotation: node.rotation,
|
||||
levelId: node.parentId ?? null,
|
||||
})
|
||||
// Single source of truth — write the absolute position straight to
|
||||
// the scene (history is paused by the overlay). Both the 2D SVG and
|
||||
// the 3D group transform read `node.position` reactively, so they
|
||||
// stay in lockstep. The overlay's snapshot-diff turns the whole drag
|
||||
// into one undoable step on commit.
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: shelfId,
|
||||
data: { position: visualPosition },
|
||||
},
|
||||
])
|
||||
lastVisualPosition = visualPosition
|
||||
useLiveNodeOverrides.getState().set(shelfId, { position: visualPosition })
|
||||
useScene.getState().markDirty(shelfId)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined
|
||||
if (live?.type !== 'shelf') return false
|
||||
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(shelfId)
|
||||
useScene.getState().updateNodes([{ id: shelfId, data: { position: lastVisualPosition } }])
|
||||
},
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user