Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export {
discoverPlugins,
getHostRefFields,
getSelectableKinds,
isDrawnViaTool,
isDrawnViaToolKind,
isPresettable,
isPresettableKind,
isRegistryMovable,
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/registry/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import {
getHostRefFields,
isDrawnViaTool,
isDrawnViaToolKind,
isPresettable,
isPresettableKind,
loadPlugin,
Expand Down Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,22 @@ export function getHostRefFields(def: AnyNodeDefinition): ReadonlyArray<string>
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> {
if (plugin.apiVersion !== HOST_API_VERSION) {
throw new Error(
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
8 changes: 7 additions & 1 deletion packages/editor/src/components/tools/fence/fence-drafting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ export type {
MovingFenceEndpoint,
MovingWallEndpoint,
SplitOrientation,
ToolDefaults,
ViewMode,
} from './store/use-editor'
export { default as useEditor } from './store/use-editor'
Expand Down
28 changes: 28 additions & 0 deletions packages/editor/src/store/use-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>

export type MovingWallEndpoint = {
wall: WallNode
endpoint: 'start' | 'end'
Expand Down Expand Up @@ -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<Record<Tool, ToolDefaults>>
setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void
structureLayer: StructureLayer
setStructureLayer: (layer: StructureLayer) => void
catalogCategory: CatalogCategory | null
Expand Down Expand Up @@ -610,6 +627,17 @@ const useEditor = create<EditorState>()(
},
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()
Expand Down
10 changes: 5 additions & 5 deletions packages/nodes/src/fence/definition.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -164,6 +160,10 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
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: {
Expand Down
81 changes: 62 additions & 19 deletions packages/nodes/src/fence/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -322,15 +331,15 @@ 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: {
center: arcCenter,
radius,
startAngle: arc.startAngle,
endAngle: arc.endAngle,
y: baseY + DRAFT_ANGLE_ARC_Y,
y: baseY + previewHeight + DRAFT_ANGLE_ARC_Y_OFFSET,
},
})
}
Expand All @@ -343,15 +352,21 @@ 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]
const length = Math.hypot(dx, dz)
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),
}
}

Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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<Group>(null)
const previewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
Expand All @@ -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

Expand Down Expand Up @@ -459,14 +494,22 @@ 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],
snappedLocal,
getReferenceSegments(walls, fences),
unit,
startingPoint.current.y,
previewHeightRef.current,
previewThicknessRef.current,
),
)
} else {
Expand Down Expand Up @@ -556,7 +599,7 @@ export const FenceTool: React.FC = () => {

return (
<group>
<CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
<CursorSphere height={previewHeight} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry />
<meshBasicMaterial
Expand Down
Loading