editor: tool-defaults seeding + drawTool capability (fence presets) (#346)

* feat(editor): tool-defaults seeding + drawTool capability; fence consumes it

Adds a generic, transient `useEditor.toolDefaults` slice keyed by tool, set
via `setToolDefaults(tool, params)`. A draw tool's create path merges its
entry when minting a node and clears it on deactivation, so a host app can
prime the next-drawn node's parameters — placing a saved preset of a drawn
kind, or a future "small / medium / large" dimension picker for
wall / slab / ceiling.

Marks the kind with `capabilities.drawTool` (helper `isDrawnViaTool`) so host
apps know to route placement through `setToolDefaults(type) + setTool(type)`
instead of cloning a finished instance.

Wires fence end-to-end: it declares `drawTool: true`, its create path merges
`toolDefaults.fence`, and the draft preview (bar geometry, cursor, HUD label
heights) reflects the seeded height/thickness so the ghost matches what will
be built. The tool clears its own defaults on unmount.

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

* fix(editor): restore dropped useEditor import in fence tool

The toolDefaults-seeding commit lost the `useEditor` import (formatter
stripped it), shipping a runtime ReferenceError when FenceTool mounts.
Re-add it.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-29 08:36:51 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent a144502c04
commit b8d94a6436
9 changed files with 159 additions and 25 deletions
+2
View File
@@ -17,6 +17,8 @@ export {
discoverPlugins, discoverPlugins,
getHostRefFields, getHostRefFields,
getSelectableKinds, getSelectableKinds,
isDrawnViaTool,
isDrawnViaToolKind,
isPresettable, isPresettable,
isPresettableKind, isPresettableKind,
isRegistryMovable, isRegistryMovable,
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod' import { z } from 'zod'
import { import {
getHostRefFields, getHostRefFields,
isDrawnViaTool,
isDrawnViaToolKind,
isPresettable, isPresettable,
isPresettableKind, isPresettableKind,
loadPlugin, loadPlugin,
@@ -124,6 +126,28 @@ describe('getHostRefFields', () => {
}) })
}) })
describe('isDrawnViaTool', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('true when capability set', () => {
const def = makeDefinition('fence', { capabilities: { drawTool: true } })
expect(isDrawnViaTool(def)).toBe(true)
})
test('false when unset or not exactly true', () => {
expect(isDrawnViaTool(makeDefinition('column'))).toBe(false)
expect(isDrawnViaTool(makeDefinition('off', { capabilities: { drawTool: false } }))).toBe(false)
})
test('isDrawnViaToolKind looks up the registry', () => {
registerNode(makeDefinition('fence', { capabilities: { drawTool: true } }))
expect(isDrawnViaToolKind('fence')).toBe(true)
expect(isDrawnViaToolKind('unknown')).toBe(false)
})
})
describe('loadPlugin', () => { describe('loadPlugin', () => {
beforeEach(() => { beforeEach(() => {
nodeRegistry._reset() nodeRegistry._reset()
+16
View File
@@ -174,6 +174,22 @@ export function getHostRefFields(def: AnyNodeDefinition): ReadonlyArray<string>
return def.capabilities.hostRefFields ?? [] return def.capabilities.hostRefFields ?? []
} }
/**
* Whether instances of this kind are created by drawing with a build tool
* (tool id === node `type`) rather than dropping a finished instance. Read
* by host apps to route preset placement of such kinds through
* `setToolDefaults(type, params)` + `setTool(type)` — see
* `def.capabilities.drawTool` docs.
*/
export function isDrawnViaTool(def: AnyNodeDefinition): boolean {
return def.capabilities.drawTool === true
}
export function isDrawnViaToolKind(kind: string): boolean {
const def = nodeRegistry.get(kind)
return def ? isDrawnViaTool(def) : false
}
export async function loadPlugin(plugin: Plugin): Promise<void> { export async function loadPlugin(plugin: Plugin): Promise<void> {
if (plugin.apiVersion !== HOST_API_VERSION) { if (plugin.apiVersion !== HOST_API_VERSION) {
throw new Error( throw new Error(
+14
View File
@@ -1008,6 +1008,20 @@ export type Capabilities = {
* are non-leaf scene containers. * are non-leaf scene containers.
*/ */
presettable?: boolean presettable?: boolean
/**
* Instances of this kind are created by operating a build tool and
* drawing on the grid (clicking points), rather than dropping a
* finished instance. The tool id equals the node `type`. Host apps may
* seed the tool's starting parameters via
* `useEditor.setToolDefaults(type, params)` before activating it — the
* tool's create path merges those defaults when minting the node and
* clears its own entry on deactivation. Used so placing a saved preset
* of a drawn kind contributes its build parameters (a fence's
* height / style / post spacing) while the user draws the fresh span,
* and so a future "small / medium / large" picker can prime the same
* tool. Read via the `isDrawnViaTool(def)` helper. Default `false`.
*/
drawTool?: boolean
} }
/** /**
@@ -8,10 +8,11 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { import {
findWallSnapTarget, findWallSnapTarget,
getWallAngleSnapStep,
getSegmentGridStep, getSegmentGridStep,
getWallAngleSnapStep,
isSegmentLongEnough, isSegmentLongEnough,
snapPointTo45Degrees, snapPointTo45Degrees,
snapPointToGrid, snapPointToGrid,
@@ -158,7 +159,12 @@ export function createFenceOnCurrentLevel(
} }
const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
// Build parameters seeded by a placed preset (height, style, post
// spacing, …) merge in first; `name`/`start`/`end` always win. The
// schema parse validates and drops anything unexpected.
const defaults = useEditor.getState().toolDefaults.fence ?? {}
const fence = FenceNode.parse({ const fence = FenceNode.parse({
...defaults,
name: `Fence ${fenceCount + 1}`, name: `Fence ${fenceCount + 1}`,
start, start,
end, end,
+1
View File
@@ -206,6 +206,7 @@ export type {
MovingFenceEndpoint, MovingFenceEndpoint,
MovingWallEndpoint, MovingWallEndpoint,
SplitOrientation, SplitOrientation,
ToolDefaults,
ViewMode, ViewMode,
} from './store/use-editor' } from './store/use-editor'
export { default as useEditor } from './store/use-editor' export { default as useEditor } from './store/use-editor'
+28
View File
@@ -115,6 +115,14 @@ export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
// Combined tool type // Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool export type Tool = SiteTool | StructureTool | FurnishTool
/**
* Starting parameters seeded into a draw tool before it mints a node.
* A loose param bag — the tool's create path validates it through the
* kind's schema (`FenceNode.parse({ ...defaults, start, end })`), which
* is the real type gate, so unknown keys are simply ignored.
*/
export type ToolDefaults = Record<string, unknown>
export type MovingWallEndpoint = { export type MovingWallEndpoint = {
wall: WallNode wall: WallNode
endpoint: 'start' | 'end' endpoint: 'start' | 'end'
@@ -156,6 +164,15 @@ type EditorState = {
setMode: (mode: Mode) => void setMode: (mode: Mode) => void
tool: Tool | null tool: Tool | null
setTool: (tool: Tool | null) => void setTool: (tool: Tool | null) => void
/**
* Per-tool starting parameters for the next node a draw tool mints.
* Transient (not persisted): host apps seed an entry just before
* activating the tool (placing a drawn preset, or a future dimension
* picker), the tool's create path merges it, and the tool clears its
* own entry on deactivation so a later manual draw isn't poisoned.
*/
toolDefaults: Partial<Record<Tool, ToolDefaults>>
setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void
structureLayer: StructureLayer structureLayer: StructureLayer
setStructureLayer: (layer: StructureLayer) => void setStructureLayer: (layer: StructureLayer) => void
catalogCategory: CatalogCategory | null catalogCategory: CatalogCategory | null
@@ -610,6 +627,17 @@ const useEditor = create<EditorState>()(
}, },
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool, tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
setTool: (tool) => set({ tool }), setTool: (tool) => set({ tool }),
toolDefaults: {},
setToolDefaults: (tool, defaults) =>
set((state) => {
const next = { ...state.toolDefaults }
if (defaults === null) {
delete next[tool]
} else {
next[tool] = defaults
}
return { toolDefaults: next }
}),
structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer, structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
setStructureLayer: (layer) => { setStructureLayer: (layer) => {
const { mode } = get() const { mode } = get()
+5 -5
View File
@@ -1,8 +1,4 @@
import { import type { FenceNode as FenceNodeType, HandleDescriptor, NodeDefinition } from '@pascal-app/core'
type FenceNode as FenceNodeType,
type HandleDescriptor,
type NodeDefinition,
} from '@pascal-app/core'
import { buildFenceFloorplan } from './floorplan' import { buildFenceFloorplan } from './floorplan'
import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances' import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances'
import { fenceFloorplanMoveTarget } from './floorplan-move' import { fenceFloorplanMoveTarget } from './floorplan-move'
@@ -164,6 +160,10 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
surfaces: { sides: { faces: 'all' } }, surfaces: { sides: { faces: 'all' } },
duplicable: true, duplicable: true,
deletable: true, deletable: true,
// Placed by drawing the span with the two-click tool; a saved preset
// seeds its build parameters via `toolDefaults.fence` (see `tool.tsx`
// and `createFenceOnCurrentLevel`).
drawTool: true,
}, },
relations: { relations: {
+62 -19
View File
@@ -25,6 +25,7 @@ import {
type SegmentAngleReference, type SegmentAngleReference,
snapFenceDraftPoint, snapFenceDraftPoint,
triggerSFX, triggerSFX,
useEditor,
WALL_FINE_GRID_STEP, WALL_FINE_GRID_STEP,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { getSceneTheme, useViewer } from '@pascal-app/viewer'
@@ -34,9 +35,11 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
const FENCE_PREVIEW_HEIGHT = 1.8 const FENCE_PREVIEW_HEIGHT = 1.8
const FENCE_PREVIEW_THICKNESS = 0.08 const FENCE_PREVIEW_THICKNESS = 0.08
const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22 // HUD label heights are measured from the top of the preview bar, so they
const DRAFT_ANGLE_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.08 // track whatever height a seeded preset draws at (`previewHeight`).
const DRAFT_ANGLE_ARC_Y = FENCE_PREVIEW_HEIGHT + 0.012 const DRAFT_LABEL_Y_OFFSET = 0.22
const DRAFT_ANGLE_LABEL_Y_OFFSET = 0.08
const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012
const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32
const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72
const DRAFT_ANGLE_ARC_SEGMENTS = 24 const DRAFT_ANGLE_ARC_SEGMENTS = 24
@@ -135,12 +138,16 @@ function toMiterWall(segment: SegmentLike): WallNode {
} }
} }
function buildDraftFenceSegment(start: FencePlanPoint, end: FencePlanPoint): SegmentLike { function buildDraftFenceSegment(
start: FencePlanPoint,
end: FencePlanPoint,
thickness: number,
): SegmentLike {
return { return {
id: 'fence_draft', id: 'fence_draft',
start, start,
end, end,
thickness: FENCE_PREVIEW_THICKNESS, thickness,
} }
} }
@@ -269,10 +276,12 @@ function getDraftAngleLabels(
end: FencePlanPoint, end: FencePlanPoint,
segments: SegmentLike[], segments: SegmentLike[],
baseY: number, baseY: number,
previewHeight: number,
previewThickness: number,
): DraftAngleLabel[] { ): DraftAngleLabel[] {
const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]] const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]] const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
const draftSegment = buildDraftFenceSegment(start, end) const draftSegment = buildDraftFenceSegment(start, end, previewThickness)
const miterData = calculateLevelMiters([...segments, draftSegment].map(toMiterWall)) const miterData = calculateLevelMiters([...segments, draftSegment].map(toMiterWall))
const endpoints = [ const endpoints = [
{ id: 'start', point: start, draftVector: draftFromStart }, { id: 'start', point: start, draftVector: draftFromStart },
@@ -322,7 +331,7 @@ function getDraftAngleLabels(
label: formatAngleRadians(angle), label: formatAngleRadians(angle),
position: [ position: [
arcCenter[0] + Math.cos(arc.midAngle) * (radius + 0.16), arcCenter[0] + Math.cos(arc.midAngle) * (radius + 0.16),
baseY + DRAFT_ANGLE_LABEL_Y, baseY + previewHeight + DRAFT_ANGLE_LABEL_Y_OFFSET,
arcCenter[1] + Math.sin(arc.midAngle) * (radius + 0.16), arcCenter[1] + Math.sin(arc.midAngle) * (radius + 0.16),
], ],
arc: { arc: {
@@ -330,7 +339,7 @@ function getDraftAngleLabels(
radius, radius,
startAngle: arc.startAngle, startAngle: arc.startAngle,
endAngle: arc.endAngle, endAngle: arc.endAngle,
y: baseY + DRAFT_ANGLE_ARC_Y, y: baseY + previewHeight + DRAFT_ANGLE_ARC_Y_OFFSET,
}, },
}) })
} }
@@ -343,6 +352,8 @@ function getDraftMeasurementState(
segments: SegmentLike[], segments: SegmentLike[],
unit: 'metric' | 'imperial', unit: 'metric' | 'imperial',
baseY: number, baseY: number,
previewHeight: number,
previewThickness: number,
): DraftMeasurementState { ): DraftMeasurementState {
const dx = end[0] - start[0] const dx = end[0] - start[0]
const dz = end[1] - start[1] const dz = end[1] - start[1]
@@ -350,8 +361,12 @@ function getDraftMeasurementState(
if (length < 0.01) return null if (length < 0.01) return null
return { return {
lengthLabel: formatMeasurement(length, unit), lengthLabel: formatMeasurement(length, unit),
lengthPosition: [(start[0] + end[0]) / 2, baseY + DRAFT_LABEL_Y, (start[1] + end[1]) / 2], lengthPosition: [
angleLabels: getDraftAngleLabels(start, end, segments, baseY), (start[0] + end[0]) / 2,
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
(start[1] + end[1]) / 2,
],
angleLabels: getDraftAngleLabels(start, end, segments, baseY, previewHeight, previewThickness),
} }
} }
@@ -374,7 +389,13 @@ function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLi
] ]
} }
function updateFencePreview(mesh: Mesh, start: Vector3, end: Vector3) { function updateFencePreview(
mesh: Mesh,
start: Vector3,
end: Vector3,
previewHeight: number,
previewThickness: number,
) {
const direction = new Vector3(end.x - start.x, 0, end.z - start.z) const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length() const length = direction.length()
if (length < 0.01) { if (length < 0.01) {
@@ -383,14 +404,10 @@ function updateFencePreview(mesh: Mesh, start: Vector3, end: Vector3) {
} }
mesh.visible = true mesh.visible = true
direction.normalize() direction.normalize()
const geometry = new BoxGeometry(length, FENCE_PREVIEW_HEIGHT, FENCE_PREVIEW_THICKNESS) const geometry = new BoxGeometry(length, previewHeight, previewThickness)
const angle = Math.atan2(direction.z, direction.x) const angle = Math.atan2(direction.z, direction.x)
mesh.position.set( mesh.position.set((start.x + end.x) / 2, start.y + previewHeight / 2, (start.z + end.z) / 2)
(start.x + end.x) / 2,
start.y + FENCE_PREVIEW_HEIGHT / 2,
(start.z + end.z) / 2,
)
mesh.rotation.y = -angle mesh.rotation.y = -angle
if (mesh.geometry) { if (mesh.geometry) {
@@ -415,6 +432,19 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
export const FenceTool: React.FC = () => { export const FenceTool: React.FC = () => {
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
// A placed preset seeds `toolDefaults.fence` before the tool mounts, so
// the draft preview is drawn at the preset's height / thickness rather
// than the generic fallbacks. Read through refs so the live event
// handlers below see the latest values without re-subscribing.
const fenceDefaults = useEditor((s) => s.toolDefaults.fence)
const previewHeight =
typeof fenceDefaults?.height === 'number' ? fenceDefaults.height : FENCE_PREVIEW_HEIGHT
const previewThickness =
typeof fenceDefaults?.thickness === 'number' ? fenceDefaults.thickness : FENCE_PREVIEW_THICKNESS
const previewHeightRef = useRef(previewHeight)
previewHeightRef.current = previewHeight
const previewThicknessRef = useRef(previewThickness)
previewThicknessRef.current = previewThickness
const cursorRef = useRef<Group>(null) const cursorRef = useRef<Group>(null)
const previewRef = useRef<Mesh>(null!) const previewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0)) const startingPoint = useRef(new Vector3(0, 0, 0))
@@ -425,6 +455,11 @@ export const FenceTool: React.FC = () => {
const measurementColor = isDark ? '#ffffff' : '#111111' const measurementColor = isDark ? '#ffffff' : '#111111'
const measurementShadowColor = isDark ? '#111111' : '#ffffff' const measurementShadowColor = isDark ? '#111111' : '#ffffff'
// Scope seeded defaults to this tool session: clear on deactivation so a
// later manual fence draw isn't drawn with a stale preset's parameters.
// Unmount-only (empty deps) — the [unit] effect below must not clear it.
useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), [])
useEffect(() => { useEffect(() => {
let previousFenceEnd: FencePlanPoint | null = null let previousFenceEnd: FencePlanPoint | null = null
@@ -459,7 +494,13 @@ export const FenceTool: React.FC = () => {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
} }
previousFenceEnd = currentFenceEnd previousFenceEnd = currentFenceEnd
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current) updateFencePreview(
previewRef.current,
startingPoint.current,
endingPoint.current,
previewHeightRef.current,
previewThicknessRef.current,
)
setDraftMeasurement( setDraftMeasurement(
getDraftMeasurementState( getDraftMeasurementState(
[startingPoint.current.x, startingPoint.current.z], [startingPoint.current.x, startingPoint.current.z],
@@ -467,6 +508,8 @@ export const FenceTool: React.FC = () => {
getReferenceSegments(walls, fences), getReferenceSegments(walls, fences),
unit, unit,
startingPoint.current.y, startingPoint.current.y,
previewHeightRef.current,
previewThicknessRef.current,
), ),
) )
} else { } else {
@@ -556,7 +599,7 @@ export const FenceTool: React.FC = () => {
return ( return (
<group> <group>
<CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} /> <CursorSphere height={previewHeight} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}> <mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry /> <shapeGeometry />
<meshBasicMaterial <meshBasicMaterial