feat(core): expose generic host integration primitives
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
resolveCeilingPlanPointSnap,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
useFloorplanDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -79,6 +80,20 @@ export const CeilingTool: React.FC = () => {
|
||||
}, [points.length])
|
||||
useEffect(() => () => useEditor.getState().setDraftVertexCount(0), [])
|
||||
|
||||
useEffect(() => {
|
||||
useFloorplanDraftPreview.getState().setPolygonDraft('ceiling', points)
|
||||
}, [points])
|
||||
useEffect(
|
||||
() => () => {
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
if (draftPreview.polygonDraftType === 'ceiling') {
|
||||
draftPreview.setPolygonDraft(null, [])
|
||||
}
|
||||
draftPreview.setCursorPoint(null)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const verticalGeo = useMemo(
|
||||
() =>
|
||||
new BufferGeometry().setFromPoints([
|
||||
@@ -117,6 +132,7 @@ export const CeilingTool: React.FC = () => {
|
||||
fallbackPoint: orthoPoint,
|
||||
levelId: currentLevelId,
|
||||
}).point
|
||||
useFloorplanDraftPreview.getState().setCursorPoint(displayPoint)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
if (
|
||||
points.length > 0 &&
|
||||
|
||||
@@ -364,6 +364,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
preview: () => import('./renderer').then(({ ColumnPreview }) => ({ default: ColumnPreview })),
|
||||
// Registry-driven placement tool — renders a translucent `ColumnPreview`
|
||||
// ghost at the cursor (mirroring the shelf build tool) instead of the
|
||||
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePathDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -559,6 +560,35 @@ const DuctSegmentTool = () => {
|
||||
// the cursor was at key-press time.
|
||||
const lastClientYRef = useRef<number | null>(null)
|
||||
|
||||
const ghostFittings = useMemo(() => {
|
||||
const last = draftPoints.at(-1)
|
||||
if (!(activeLevelId && last && cursorPos) || altActive) return []
|
||||
const fittings =
|
||||
planDuctDraw(
|
||||
last,
|
||||
cursorPos,
|
||||
startPortRef.current,
|
||||
startBodyRef.current,
|
||||
endSnap.port,
|
||||
endSnap.body,
|
||||
profile,
|
||||
)?.fittings ?? []
|
||||
return fittings.map(
|
||||
(fitting, index): DuctFittingNode => ({
|
||||
...fitting,
|
||||
id: `duct-fitting_live-draft-${index}`,
|
||||
parentId: activeLevelId,
|
||||
}),
|
||||
)
|
||||
}, [activeLevelId, altActive, cursorPos, draftPoints, endSnap, profile])
|
||||
|
||||
useEffect(() => {
|
||||
usePathDraftPreview
|
||||
.getState()
|
||||
.setDraft('duct-segment', draftPoints, cursorPos, profile, ghostFittings)
|
||||
}, [cursorPos, draftPoints, ghostFittings, profile])
|
||||
useEffect(() => () => usePathDraftPreview.getState().clear('duct-segment'), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
@@ -927,22 +957,6 @@ const DuctSegmentTool = () => {
|
||||
previewSegments.push({ a: last, b: cursorPos })
|
||||
}
|
||||
|
||||
// Ghost the auto-inserted fittings (elbow / tee / cross) the next click
|
||||
// will mint, by running the SAME planner the commit uses against the
|
||||
// in-flight endpoints. Skipped in Alt-vertical mode (no XZ tap there).
|
||||
const ghostFittings =
|
||||
last && cursorPos && !altActive
|
||||
? (planDuctDraw(
|
||||
last,
|
||||
cursorPos,
|
||||
startPortRef.current,
|
||||
startBodyRef.current,
|
||||
endSnap.port,
|
||||
endSnap.body,
|
||||
profile,
|
||||
)?.fittings ?? [])
|
||||
: []
|
||||
|
||||
// Wall-style dimension pill above the cursor: absolute world coords before
|
||||
// the first point, signed per-axis deltas from the last placed point while
|
||||
// a segment is in flight. The actively-driven axis is emphasised — Y in
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useFenceCurveDraft,
|
||||
useFloorplanDraftPreview,
|
||||
useSegmentDraftChain,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -511,6 +512,9 @@ const StraightFenceTool: React.FC = () => {
|
||||
buildingState.current = 0
|
||||
previewRef.current.visible = false
|
||||
setDraftMeasurement(null)
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setFenceDraftStart(null)
|
||||
draftPreview.setFenceDraftEnd(null)
|
||||
useSegmentDraftChain.getState().clear('fence')
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
@@ -538,6 +542,9 @@ const StraightFenceTool: React.FC = () => {
|
||||
{ applySnap: !angleLocked },
|
||||
)
|
||||
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z])
|
||||
draftPreview.setFenceDraftEnd(snappedLocal)
|
||||
cursorRef.current.position.copy(endingPoint.current)
|
||||
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
|
||||
if (
|
||||
@@ -601,6 +608,9 @@ const StraightFenceTool: React.FC = () => {
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setFenceDraftStart(snappedStart)
|
||||
draftPreview.setFenceDraftEnd(snappedStart)
|
||||
triggerSFX('sfx:structure-build-start')
|
||||
previewRef.current.visible = true
|
||||
setDraftMeasurement(null)
|
||||
@@ -645,6 +655,10 @@ const StraightFenceTool: React.FC = () => {
|
||||
useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]])
|
||||
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setFenceDraftEnd(null)
|
||||
draftPreview.setFenceDraftStart(nextStart)
|
||||
draftPreview.setFenceDraftEnd(nextStart)
|
||||
cursorRef.current?.position.copy(startingPoint.current)
|
||||
previewRef.current.visible = false
|
||||
buildingState.current = 1
|
||||
@@ -669,6 +683,9 @@ const StraightFenceTool: React.FC = () => {
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useSegmentDraftChain.getState().clear('fence')
|
||||
useAlignmentGuides.getState().clear()
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setFenceDraftStart(null)
|
||||
draftPreview.setFenceDraftEnd(null)
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
@@ -725,11 +742,11 @@ const SplineFenceDraft: React.FC = () => {
|
||||
|
||||
draftRef.current = draftPoints
|
||||
|
||||
// Mirror the draft length into the HUD store so the "finish curve" hint only
|
||||
// shows once drafting has started; always clear it when the tool unmounts.
|
||||
// Mirror the full transient curve so additional preview surfaces observe
|
||||
// the same snapped control points as the local preview.
|
||||
useEffect(() => {
|
||||
useFenceCurveDraft.getState().setPointCount(draftPoints.length)
|
||||
}, [draftPoints])
|
||||
useFenceCurveDraft.getState().setDraft(draftPoints, cursor)
|
||||
}, [cursor, draftPoints])
|
||||
useEffect(() => () => useFenceCurveDraft.getState().reset(), [])
|
||||
|
||||
useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), [])
|
||||
|
||||
@@ -9,7 +9,7 @@ import { LevelNode } from './schema'
|
||||
*/
|
||||
export const levelDefinition: NodeDefinition<typeof LevelNode> = {
|
||||
kind: 'level',
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
schema: LevelNode,
|
||||
category: 'site',
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePathDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -98,6 +99,11 @@ const LinesetTool = () => {
|
||||
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
|
||||
const lastClientYRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
usePathDraftPreview.getState().setDraft('lineset', draftPoints, cursorPos)
|
||||
}, [cursorPos, draftPoints])
|
||||
useEffect(() => () => usePathDraftPreview.getState().clear('lineset'), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePathDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -285,6 +286,13 @@ const LiquidLineTool = () => {
|
||||
// Leaving the tool clears Follow so re-arming it starts in free-draw.
|
||||
useEffect(() => () => useLiquidLineToolOptions.getState().setFollow(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
usePathDraftPreview
|
||||
.getState()
|
||||
.setDraft('liquid-line', traceGhost ?? draftPoints, traceGhost ? null : cursorPos)
|
||||
}, [cursorPos, draftPoints, traceGhost])
|
||||
useEffect(() => () => usePathDraftPreview.getState().clear('liquid-line'), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePathDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -162,6 +163,30 @@ const PipeSegmentTool = () => {
|
||||
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
|
||||
const lastClientYRef = useRef<number | null>(null)
|
||||
|
||||
const displayStart =
|
||||
draftStart &&
|
||||
cursorPos &&
|
||||
sloped &&
|
||||
system === 'waste' &&
|
||||
!startPortRef.current &&
|
||||
!startBodyRef.current &&
|
||||
!snapTarget &&
|
||||
!altActive
|
||||
? ([
|
||||
draftStart[0],
|
||||
draftStart[1] +
|
||||
Math.hypot(cursorPos[0] - draftStart[0], cursorPos[2] - draftStart[2]) * DRAIN_SLOPE,
|
||||
draftStart[2],
|
||||
] as [number, number, number])
|
||||
: draftStart
|
||||
|
||||
useEffect(() => {
|
||||
usePathDraftPreview
|
||||
.getState()
|
||||
.setDraft('pipe-segment', displayStart ? [displayStart] : [], cursorPos, { diameter, system })
|
||||
}, [cursorPos, diameter, displayStart, system])
|
||||
useEffect(() => () => usePathDraftPreview.getState().clear('pipe-segment'), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
@@ -613,26 +638,6 @@ const PipeSegmentTool = () => {
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
// Free waste start lifts at commit so the run falls ONTO the grid —
|
||||
// mirror that here so the preview line / pill match the placed pipe.
|
||||
// A snapped end (snapTarget set) keeps the start where it is.
|
||||
const displayStart =
|
||||
draftStart &&
|
||||
cursorPos &&
|
||||
sloped &&
|
||||
system === 'waste' &&
|
||||
!startPortRef.current &&
|
||||
!startBodyRef.current &&
|
||||
!snapTarget &&
|
||||
!altActive
|
||||
? ([
|
||||
draftStart[0],
|
||||
draftStart[1] +
|
||||
Math.hypot(cursorPos[0] - draftStart[0], cursorPos[2] - draftStart[2]) * DRAIN_SLOPE,
|
||||
draftStart[2],
|
||||
] as [number, number, number])
|
||||
: draftStart
|
||||
|
||||
const pillParts = cursorPos
|
||||
? [
|
||||
...(['x', 'y', 'z'] as const).map((axis, i) => ({
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolveSlabPlanPointSnap,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
useFloorplanDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -77,6 +78,20 @@ export const SlabTool: React.FC = () => {
|
||||
}, [points.length])
|
||||
useEffect(() => () => useEditor.getState().setDraftVertexCount(0), [])
|
||||
|
||||
useEffect(() => {
|
||||
useFloorplanDraftPreview.getState().setPolygonDraft('slab', points)
|
||||
}, [points])
|
||||
useEffect(
|
||||
() => () => {
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
if (draftPreview.polygonDraftType === 'slab') {
|
||||
draftPreview.setPolygonDraft(null, [])
|
||||
}
|
||||
draftPreview.setCursorPoint(null)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
@@ -101,6 +116,7 @@ export const SlabTool: React.FC = () => {
|
||||
fallbackPoint: orthoPoint,
|
||||
levelId: currentLevelId,
|
||||
}).point
|
||||
useFloorplanDraftPreview.getState().setCursorPoint(displayPoint)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
if (
|
||||
points.length > 0 &&
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useFloorplanDraftPreview,
|
||||
useSegmentDraftChain,
|
||||
useWallSnapIndicator,
|
||||
WALL_CONNECT_SNAP_RADIUS,
|
||||
@@ -594,6 +595,9 @@ export const WallTool: React.FC = () => {
|
||||
buildingState.current = 0
|
||||
chainFirstVertex.current = null
|
||||
chainWallIds.current = []
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setWallDraftStart(null)
|
||||
draftPreview.setWallDraftEnd(null)
|
||||
if (wallPreviewRef.current) {
|
||||
wallPreviewRef.current.visible = false
|
||||
}
|
||||
@@ -637,6 +641,9 @@ export const WallTool: React.FC = () => {
|
||||
if (buildingState.current === 1) {
|
||||
const snappedLocal = gridPosition
|
||||
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setWallDraftStart([startingPoint.current.x, startingPoint.current.z])
|
||||
draftPreview.setWallDraftEnd(snappedLocal)
|
||||
cursorRef.current.position.copy(endingPoint.current)
|
||||
setAxisGuide({
|
||||
origin: [startingPoint.current.x, startingPoint.current.z],
|
||||
@@ -706,6 +713,9 @@ export const WallTool: React.FC = () => {
|
||||
chainFirstVertex.current = startingPoint.current.clone()
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setWallDraftStart(snappedStart)
|
||||
draftPreview.setWallDraftEnd(snappedStart)
|
||||
setAxisGuide({
|
||||
origin: snappedStart,
|
||||
y: event.localPosition[1],
|
||||
@@ -783,6 +793,10 @@ export const WallTool: React.FC = () => {
|
||||
useSegmentDraftChain.getState().setChainStart('wall', [nextStart[0], nextStart[1]])
|
||||
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setWallDraftEnd(null)
|
||||
draftPreview.setWallDraftStart(nextStart)
|
||||
draftPreview.setWallDraftEnd(nextStart)
|
||||
cursorRef.current?.position.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
setAxisGuide({
|
||||
@@ -820,6 +834,9 @@ export const WallTool: React.FC = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
useSegmentDraftChain.getState().clear('wall')
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setWallDraftStart(null)
|
||||
draftPreview.setWallDraftEnd(null)
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user