feat(core): expose generic host integration primitives

This commit is contained in:
Aymeric Rabot
2026-07-19 17:11:43 +02:00
parent 2084892383
commit 8f1c834e56
39 changed files with 2471 additions and 166 deletions
+16
View File
@@ -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 &&
+1
View File
@@ -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
+30 -16
View File
@@ -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
+21 -4
View File
@@ -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), [])
+1 -1
View File
@@ -9,7 +9,7 @@ import { LevelNode } from './schema'
*/
export const levelDefinition: NodeDefinition<typeof LevelNode> = {
kind: 'level',
schemaVersion: 1,
schemaVersion: 2,
schema: LevelNode,
category: 'site',
+6
View File
@@ -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
+8
View File
@@ -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
+25 -20
View File
@@ -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) => ({
+16
View File
@@ -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 &&
+17
View File
@@ -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])