From 7039ef06db1967ef141cc26ca403acfeccf398d3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 29 May 2026 08:18:17 -0400 Subject: [PATCH 1/2] feat(editor): tool-defaults seeding + drawTool capability; fence consumes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/src/registry/index.ts | 2 + packages/core/src/registry/registry.test.ts | 24 ++++++ packages/core/src/registry/registry.ts | 16 ++++ packages/core/src/registry/types.ts | 14 ++++ .../components/tools/fence/fence-drafting.ts | 8 +- packages/editor/src/index.tsx | 1 + packages/editor/src/store/use-editor.tsx | 28 +++++++ packages/nodes/src/fence/definition.ts | 10 +-- packages/nodes/src/fence/tool.tsx | 80 ++++++++++++++----- 9 files changed, 158 insertions(+), 25 deletions(-) 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..29a6c8f47 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -34,9 +34,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 +137,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 +275,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 +330,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 +338,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 +351,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 +360,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 +388,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 +403,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 +431,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 +454,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 +493,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 +507,8 @@ export const FenceTool: React.FC = () => { getReferenceSegments(walls, fences), unit, startingPoint.current.y, + previewHeightRef.current, + previewThicknessRef.current, ), ) } else { @@ -556,7 +598,7 @@ export const FenceTool: React.FC = () => { return ( - + Date: Fri, 29 May 2026 08:28:34 -0400 Subject: [PATCH 2/2] 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 --- packages/nodes/src/fence/tool.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 29a6c8f47..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'