135 lines
5.0 KiB
TypeScript
135 lines
5.0 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
analyzePortConnectivity,
|
|
type FloorplanAffordance,
|
|
type FloorplanAffordanceSession,
|
|
type PortConnectivity,
|
|
resolveConnectivityUpdates,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
|
|
|
|
/**
|
|
* Shared "side-move a path segment" floor-plan affordance for polyline
|
|
* distribution kinds (duct-segment / pipe-segment). It is the 2D counterpart
|
|
* of the in-world side-move arrows in the kind's 3D
|
|
* `affordanceTools.selection` handles.
|
|
*
|
|
* - **move-segment**: slide one segment perpendicular to itself. Both its
|
|
* vertices translate by the same plan-normal offset (the offset is the
|
|
* cursor's projection onto the segment normal); neighbours stretch and any
|
|
* mated joint follows via port connectivity. Grid-snapped (Shift bypasses).
|
|
*
|
|
* The vertices' Y (elevation) is always held — plan editing never changes
|
|
* height, matching the path-point affordance. Behavioral parity with the 3D
|
|
* selection arrows. (Length editing stays on the per-vertex hex handles.)
|
|
*
|
|
* Wired via `def.floorplanAffordances['move-segment']`; the floor-plan
|
|
* builder emits `move-arrow` primitives carrying the segment index so the
|
|
* dispatcher routes pointer-downs here.
|
|
*/
|
|
export type SegmentMovePayload = {
|
|
/** Index of the segment's first vertex (it spans [i, i+1]). */
|
|
segmentIndex: number
|
|
/** Unit plan normal [nx, nz] the segment slides along. */
|
|
normal: [number, number]
|
|
}
|
|
|
|
type Point = [number, number, number]
|
|
type PathShape = { path: ReadonlyArray<readonly [number, number, number]>; id: AnyNodeId }
|
|
|
|
const inert: FloorplanAffordanceSession = {
|
|
affectedIds: [],
|
|
apply() {},
|
|
canCommit() {
|
|
return false
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Connectivity snapshot + follow-update builder. Endpoints bear ports; an
|
|
* interior segment vertex never does, so the caller passes `analyze: false`
|
|
* to skip the work when neither moved vertex is a run end.
|
|
*/
|
|
function makeConnectivity<N extends PathShape>(
|
|
node: N,
|
|
nodes: Record<AnyNodeId, AnyNode>,
|
|
analyze: boolean,
|
|
): {
|
|
connectivity: PortConnectivity | null
|
|
affectedIds: AnyNodeId[]
|
|
followUpdates: (nextPath: Point[]) => { id: AnyNodeId; data: Partial<AnyNode> }[]
|
|
} {
|
|
const connectivity = analyze ? analyzePortConnectivity(node as unknown as AnyNode, nodes) : null
|
|
const affectedIds: AnyNodeId[] = [
|
|
node.id,
|
|
...(connectivity?.connections.map((c) => c.nodeId) ?? []),
|
|
]
|
|
const followUpdates = (nextPath: Point[]) => {
|
|
if (!connectivity) return []
|
|
const preview = {
|
|
...(node as unknown as Record<string, unknown>),
|
|
path: nextPath,
|
|
} as AnyNode
|
|
return resolveConnectivityUpdates(connectivity, preview).filter(
|
|
(u) => useScene.getState().nodes[u.id],
|
|
)
|
|
}
|
|
return { connectivity, affectedIds, followUpdates }
|
|
}
|
|
|
|
export function createSegmentMoveAffordance<N extends PathShape>(
|
|
kind: string,
|
|
): FloorplanAffordance<N> {
|
|
return {
|
|
start({ node, payload, nodes }): FloorplanAffordanceSession {
|
|
const { segmentIndex, normal } = payload as SegmentMovePayload
|
|
const initialPath = node.path.map((p) => [...p] as Point)
|
|
const a = initialPath[segmentIndex]
|
|
const b = initialPath[segmentIndex + 1]
|
|
if (!a || !b) return { ...inert, affectedIds: [node.id] }
|
|
const lastIndex = initialPath.length - 1
|
|
// A moved vertex bears a port only if it's a run end.
|
|
const touchesEnd = segmentIndex === 0 || segmentIndex + 1 === lastIndex
|
|
const { affectedIds, followUpdates } = makeConnectivity(node, nodes, touchesEnd)
|
|
const mid: WallPlanPoint = [(a[0] + b[0]) / 2, (a[2] + b[2]) / 2]
|
|
|
|
return {
|
|
affectedIds,
|
|
apply({ planPoint, modifiers }) {
|
|
// Project the cursor onto the segment normal — that signed distance
|
|
// is how far the whole segment slides. Grid-snap the magnitude
|
|
// (Shift bypasses) so the slide lands on the same lattice as the
|
|
// other plan tools.
|
|
const signedRaw =
|
|
(planPoint[0] - mid[0]) * normal[0] + (planPoint[1] - mid[1]) * normal[1]
|
|
const signed = modifiers.shiftKey ? signedRaw : snapPointToGrid([signedRaw, 0])[0]
|
|
const ox = normal[0] * signed
|
|
const oz = normal[1] * signed
|
|
const nextPath = initialPath.map((p, i) =>
|
|
i === segmentIndex || i === segmentIndex + 1
|
|
? ([p[0] + ox, p[1], p[2] + oz] as Point)
|
|
: p,
|
|
)
|
|
useScene.getState().updateNodes([
|
|
{ id: node.id, data: { path: nextPath } as Partial<unknown> as never },
|
|
...followUpdates(nextPath).map((u) => ({
|
|
id: u.id,
|
|
data: u.data as Partial<unknown> as never,
|
|
})),
|
|
])
|
|
},
|
|
canCommit() {
|
|
const final = useScene.getState().nodes[node.id] as N | undefined
|
|
return (
|
|
!!final &&
|
|
(final as unknown as { type: string }).type === kind &&
|
|
final.path.length >= 2
|
|
)
|
|
},
|
|
}
|
|
},
|
|
}
|
|
}
|