diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index d3c8e8833..b3226b31e 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -17,6 +17,8 @@ export { discoverPlugins, getHostRefFields, getSelectableKinds, + isDrawnViaTool, + isDrawnViaToolKind, isPresettable, isPresettableKind, isRegistryMovable, diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts index 48f42ffa1..1052e6984 100644 --- a/packages/core/src/registry/registry.test.ts +++ b/packages/core/src/registry/registry.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { z } from 'zod' import { getHostRefFields, + isDrawnViaTool, + isDrawnViaToolKind, isPresettable, isPresettableKind, 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', () => { beforeEach(() => { nodeRegistry._reset() diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 9968e5906..051176fcd 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -174,6 +174,22 @@ export function getHostRefFields(def: AnyNodeDefinition): ReadonlyArray 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 { if (plugin.apiVersion !== HOST_API_VERSION) { throw new Error( diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 7eb93f755..5492da86d 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1008,6 +1008,20 @@ export type Capabilities = { * are non-leaf scene containers. */ 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 } /** diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index 752e8fb86..ff09a30c4 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -8,10 +8,11 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' import { findWallSnapTarget, - getWallAngleSnapStep, getSegmentGridStep, + getWallAngleSnapStep, isSegmentLongEnough, snapPointTo45Degrees, snapPointToGrid, @@ -158,7 +159,12 @@ export function createFenceOnCurrentLevel( } 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({ + ...defaults, name: `Fence ${fenceCount + 1}`, start, end, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index a935cfc71..6d7e442ad 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -206,6 +206,7 @@ export type { MovingFenceEndpoint, MovingWallEndpoint, SplitOrientation, + ToolDefaults, ViewMode, } from './store/use-editor' export { default as useEditor } from './store/use-editor' diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index b1b37adc0..232d3532e 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -115,6 +115,14 @@ export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05 // Combined tool type 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 + export type MovingWallEndpoint = { wall: WallNode endpoint: 'start' | 'end' @@ -156,6 +164,15 @@ type EditorState = { setMode: (mode: Mode) => void tool: Tool | null 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> + setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void structureLayer: StructureLayer setStructureLayer: (layer: StructureLayer) => void catalogCategory: CatalogCategory | null @@ -610,6 +627,17 @@ const useEditor = create()( }, tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.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, setStructureLayer: (layer) => { const { mode } = get() diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index bc61ada5d..dc6e5fbd0 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -1,8 +1,4 @@ -import { - type FenceNode as FenceNodeType, - type HandleDescriptor, - type NodeDefinition, -} from '@pascal-app/core' +import type { FenceNode as FenceNodeType, HandleDescriptor, NodeDefinition } from '@pascal-app/core' import { buildFenceFloorplan } from './floorplan' import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances' import { fenceFloorplanMoveTarget } from './floorplan-move' @@ -164,6 +160,10 @@ export const fenceDefinition: NodeDefinition = { surfaces: { sides: { faces: 'all' } }, duplicable: 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: { diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 8ed0ed7a6..956a57b53 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -25,6 +25,7 @@ import { type SegmentAngleReference, snapFenceDraftPoint, triggerSFX, + useEditor, WALL_FINE_GRID_STEP, } from '@pascal-app/editor' 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_THICKNESS = 0.08 -const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22 -const DRAFT_ANGLE_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.08 -const DRAFT_ANGLE_ARC_Y = FENCE_PREVIEW_HEIGHT + 0.012 +// HUD label heights are measured from the top of the preview bar, so they +// track whatever height a seeded preset draws at (`previewHeight`). +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_MAX_RADIUS = 0.72 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 { id: 'fence_draft', start, end, - thickness: FENCE_PREVIEW_THICKNESS, + thickness, } } @@ -269,10 +276,12 @@ function getDraftAngleLabels( end: FencePlanPoint, segments: SegmentLike[], baseY: number, + previewHeight: number, + previewThickness: number, ): DraftAngleLabel[] { const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[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 endpoints = [ { id: 'start', point: start, draftVector: draftFromStart }, @@ -322,7 +331,7 @@ function getDraftAngleLabels( label: formatAngleRadians(angle), position: [ 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), ], arc: { @@ -330,7 +339,7 @@ function getDraftAngleLabels( radius, startAngle: arc.startAngle, 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[], unit: 'metric' | 'imperial', baseY: number, + previewHeight: number, + previewThickness: number, ): DraftMeasurementState { const dx = end[0] - start[0] const dz = end[1] - start[1] @@ -350,8 +361,12 @@ function getDraftMeasurementState( if (length < 0.01) return null return { lengthLabel: formatMeasurement(length, unit), - lengthPosition: [(start[0] + end[0]) / 2, baseY + DRAFT_LABEL_Y, (start[1] + end[1]) / 2], - angleLabels: getDraftAngleLabels(start, end, segments, baseY), + lengthPosition: [ + (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 length = direction.length() if (length < 0.01) { @@ -383,14 +404,10 @@ function updateFencePreview(mesh: Mesh, start: Vector3, end: Vector3) { } mesh.visible = true 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) - mesh.position.set( - (start.x + end.x) / 2, - start.y + FENCE_PREVIEW_HEIGHT / 2, - (start.z + end.z) / 2, - ) + mesh.position.set((start.x + end.x) / 2, start.y + previewHeight / 2, (start.z + end.z) / 2) mesh.rotation.y = -angle if (mesh.geometry) { @@ -415,6 +432,19 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } { export const FenceTool: React.FC = () => { const unit = useViewer((state) => state.unit) 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(null) const previewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) @@ -425,6 +455,11 @@ export const FenceTool: React.FC = () => { const measurementColor = isDark ? '#ffffff' : '#111111' 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(() => { let previousFenceEnd: FencePlanPoint | null = null @@ -459,7 +494,13 @@ export const FenceTool: React.FC = () => { triggerSFX('sfx:grid-snap') } previousFenceEnd = currentFenceEnd - updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current) + updateFencePreview( + previewRef.current, + startingPoint.current, + endingPoint.current, + previewHeightRef.current, + previewThicknessRef.current, + ) setDraftMeasurement( getDraftMeasurementState( [startingPoint.current.x, startingPoint.current.z], @@ -467,6 +508,8 @@ export const FenceTool: React.FC = () => { getReferenceSegments(walls, fences), unit, startingPoint.current.y, + previewHeightRef.current, + previewThicknessRef.current, ), ) } else { @@ -556,7 +599,7 @@ export const FenceTool: React.FC = () => { return ( - +