Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3731eb3
Add roof surface placement support for items
sudhir9297 May 18, 2026
ed53bc2
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
fd8e02c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
7c1e383
fixed conflict
sudhir9297 May 20, 2026
b3377da
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
f177a65
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
9af7491
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
fd27524
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 27, 2026
b516298
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 28, 2026
ebfc8ce
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 3, 2026
b7b313b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
b2ad645
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
bffdb4a
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 8, 2026
ee7b10c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 9, 2026
7d4b474
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 10, 2026
3a3318c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 13, 2026
26df69f
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 17, 2026
5376e07
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 22, 2026
d2204aa
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 23, 2026
f2a5186
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 29, 2026
5841052
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 1, 2026
a6acaa3
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 8, 2026
e0fec5b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 10, 2026
7fa9276
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 13, 2026
c3ff9d6
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 14, 2026
dfc8ddf
fix wall treatment miter geometry
sudhir9297 Jul 14, 2026
c63e9b1
fix cabinet group resizing and corner alignment
sudhir9297 Jul 14, 2026
ccbe302
fix cabinet corner depth resizing
sudhir9297 Jul 14, 2026
2b1577b
fix(cabinet): stabilize modular preset changes
sudhir9297 Jul 15, 2026
cbfd128
fix(cabinet): stabilize corner resizing and wall alignment
sudhir9297 Jul 15, 2026
5082192
fix(nodes): stabilize wall cabinet depth resizing
sudhir9297 Jul 15, 2026
f0fa9bf
fix(editor): hide cabinet arrows for module selection
sudhir9297 Jul 16, 2026
18d8f90
feat(cabinet): add individual width resize handles
sudhir9297 Jul 16, 2026
944a75a
feat(cabinet): improve wall cabinet editing
sudhir9297 Jul 16, 2026
bfa0e90
fix(cabinet): harden wall cabinet resizing
sudhir9297 Jul 16, 2026
a839fd5
fix(cabinet): correct wall drag and depth handles
sudhir9297 Jul 17, 2026
04a87f3
fix(cabinet): refine individual depth resizing
sudhir9297 Jul 17, 2026
27721eb
fix(cabinet): preserve context-aware corner depth behavior
sudhir9297 Jul 17, 2026
adedce3
fix(editor): respect snapping modes for resize handles
sudhir9297 Jul 17, 2026
908d581
fix(editor): harden cabinet resize interactions
Aymericr Jul 19, 2026
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
17 changes: 16 additions & 1 deletion packages/core/src/registry/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ export type LinearResizeHandle<N> = {
anchor: HandleAnchor
currentValue: (node: N) => number
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
/**
* Additional live-only patches for geometry owned by related nodes. The
* editor publishes these during the drag and clears them on release or
* cancellation; committed scene writes remain the responsibility of
* `commit` (or the generic selected-node update).
*/
previewOverrides?: (
node: N,
newValue: number,
sceneApi: SceneApi,
) => ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>
/** Optional live-scene visibility gate for context-dependent arrows. */
visible?: (node: N, sceneApi: SceneApi) => boolean
/**
Expand Down Expand Up @@ -163,6 +174,8 @@ export type LinearResizeHandle<N> = {
max?: number | ((node: N, sceneApi: SceneApi) => number)
/** Snap the resized scalar to the editor's active grid step before apply. */
gridSnap?: boolean
/** Kind-owned magnetic snap for the resized scalar, gated by the active snapping mode. */
magneticSnap?: (node: N, newValue: number, sceneApi: SceneApi) => number
placement: HandlePlacement<N>
/**
* Dimension this handle steers (e.g. `'height'`). When set, the editor
Expand Down Expand Up @@ -444,4 +457,6 @@ export type HandleDescriptor<N = any> =
* Static array, or a function for shape-dependent cases (column
* crossSection / supportStyle, stair-segment segmentType, etc.).
*/
export type HandleList<N> = HandleDescriptor<N>[] | ((node: N) => HandleDescriptor<N>[])
export type HandleList<N> =
| HandleDescriptor<N>[]
| ((node: N, sceneApi?: SceneApi) => HandleDescriptor<N>[])
1 change: 1 addition & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export type {
NodePort,
NodeQuickAction,
NodeQuickActionIcon,
NodeQuickActionNodeScope,
NodeQuickActionProvider,
NodeQuickActionResult,
NodeRegistry,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* and runs through `SceneApi`.
*/
quickActions?: NodeQuickActionProvider<z.infer<S>>
/** Scene-graph scope the quick-action provider needs for derived availability. */
quickActionNodeScope?: NodeQuickActionNodeScope
/**
* Sidebar-tree presentation hooks. Lets a kind reshape how the generic
* scene tree walks its subtree — hiding derived/managed nodes and
Expand Down Expand Up @@ -1562,6 +1564,8 @@ export type NodeQuickActionResult = {
selectedIds?: AnyNodeId[]
}

export type NodeQuickActionNodeScope = 'family' | 'level'

export type NodeQuickAction = {
id: string
label: string
Expand All @@ -1574,6 +1578,8 @@ export type NodeQuickAction = {
*/
icon?: NodeQuickActionIcon | IconRef
disabled?: boolean
/** Whether pressing a disabled action should acknowledge its blocked state. */
blockedFeedback?: boolean
history?: 'single'
run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/schema/nodes/cabinet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export type CabinetCompartmentSchema = z.infer<typeof CabinetCompartment>
const cabinetBoxFields = {
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
width: z.number().min(0.05).max(3).default(0.6),
depth: z.number().min(0.3).max(1.2).default(0.58),
width: z.number().min(0.05).max(3).default(0.5),
depth: z.number().min(0.3).max(1.2).default(0.5),
carcassHeight: z.number().min(0.4).max(2.4).default(0.72),
operationState: z.number().min(0).max(1).default(0),
plinthHeight: z.number().min(0).max(0.3).default(0.1),
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/systems/wall/wall-mitering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,17 @@ describe('wall mitering miter limit', () => {
expect(startSideX).toBeGreaterThan(-0.5)
})
})

describe('wall miter boundary sides', () => {
test('keeps left and right on the same physical face at both free endpoints', () => {
const node = wall('A', [0, 0], [3, 0])
const boundary = getWallMiterBoundaryPoints(node, calculateLevelMiters([node]))
expect(boundary).not.toBeNull()
if (!boundary) throw new Error('expected miter boundary points')

expect(boundary.startLeft.y).toBeCloseTo(0.05)
expect(boundary.endLeft.y).toBeCloseTo(0.05)
expect(boundary.startRight.y).toBeCloseTo(-0.05)
expect(boundary.endRight.y).toBeCloseTo(-0.05)
})
})
14 changes: 7 additions & 7 deletions packages/core/src/systems/wall/wall-mitering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,8 @@ function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
endType === 'start'
? { x: wall.start[0], y: wall.start[1] }
: { x: wall.end[0], y: wall.end[1] }
const vector =
endType === 'start'
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
const length = Math.hypot(vector.x, vector.y)
const direction = { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
const length = Math.hypot(direction.x, direction.y)

if (length < 1e-9) {
return {
Expand All @@ -194,8 +191,11 @@ function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {

return {
point,
tangent: { x: vector.x / length, y: vector.y / length },
normal: { x: -vector.y / length, y: vector.x / length },
tangent:
endType === 'start'
? { x: direction.x / length, y: direction.y / length }
: { x: -direction.x / length, y: -direction.y / length },
normal: { x: -direction.y / length, y: direction.x / length },
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
// which lag the `nodes` commit by a frame or two.
const meshEpoch = useMeshSettleEpoch(nodes)
const box = useMemo(() => {
void meshEpoch
if (selectedIds.length < 2 || !levelId) return null
const participantIds = selectedIds.filter(
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
Expand All @@ -630,7 +631,6 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
width: Math.abs(max.x - min.x),
depth: Math.abs(max.z - min.z),
}
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
}, [selectedIds, levelId, nodes, meshEpoch])

if (!box || movingNode || mode === 'delete') return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,19 @@ import {
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
import { type MouseEvent, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { useReducedMotion } from '../../hooks/use-reduced-motion'
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
import {
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu'
Expand Down Expand Up @@ -80,26 +85,9 @@ function collectQuickActionNodes(
): Record<AnyNodeId, AnyNode> | null {
if (!selectedId) return null
const selected = nodes[selectedId as AnyNodeId]
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null

const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
const add = (id: string | null | undefined) => {
if (!id) return
const node = nodes[id as AnyNodeId]
if (node) collected[node.id as AnyNodeId] = node
}
const addChildren = (node: AnyNode | undefined) => {
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
add(childId)
}
}

add(selected.parentId ?? null)
addChildren(selected)
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
addChildren(parent)

return collected
const def = selected ? nodeRegistry.get(selected.type) : undefined
if (!def?.quickActions) return null
return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope)
}

/**
Expand Down Expand Up @@ -129,6 +117,7 @@ function collectQuickActionNodes(
* Hidden while in a move state (so we don't show buttons over a ghost).
*/
export function FloorplanRegistryActionMenu() {
const reducedMotion = useReducedMotion()
// Sole selection only — a multi-selection gets the group menu
// (`FloorplanGroupActionMenu`), whose actions target the whole selection.
const selectedId = useViewer((s) =>
Expand Down Expand Up @@ -240,7 +229,8 @@ export function FloorplanRegistryActionMenu() {

const handleMove = () => {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never)
const sceneNodes = useScene.getState().nodes
setMovingNode(resolveMoveActionNode(node, sceneNodes) as never)
// 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
// Mark the origin (after `setMovingNode`, which resets it to null) so
// `ToolManager` keeps the 3D affordance mover from also adopting the node
Expand Down Expand Up @@ -333,8 +323,13 @@ export function FloorplanRegistryActionMenu() {
useViewer.getState().setSelection({ selectedIds: [] })
}

const handleQuickAction = (action: NodeQuickAction) => {
if (action.disabled) return
const handleQuickAction = (action: NodeQuickAction, event: MouseEvent<HTMLButtonElement>) => {
if (action.disabled) {
if (action.blockedFeedback) {
playBlockedQuickActionFeedback(event.currentTarget, reducedMotion)
}
return
}
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds })
Expand Down Expand Up @@ -369,18 +364,27 @@ export function FloorplanRegistryActionMenu() {
>
{quickActions.map((action) => (
<button
aria-disabled={action.disabled || undefined}
aria-label={action.title ?? action.label}
className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
disabled={action.disabled}
className={cn(
'tooltip-trigger flex items-center rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground',
action.disabled &&
'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
)}
disabled={action.disabled && !action.blockedFeedback}
key={action.id}
onClick={() => handleQuickAction(action)}
onClick={(event) => handleQuickAction(action, event)}
title={action.title ?? action.label}
type="button"
>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
<QuickActionIcon action={action} />
<span className="flex items-center gap-1.5" data-quick-action-feedback>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
<QuickActionIcon action={action} />
</span>
<span className="whitespace-nowrap leading-none" data-quick-action-label>
{action.label}
</span>
</span>
<span className="whitespace-nowrap leading-none">{action.label}</span>
</button>
))}
</div>
Expand Down
63 changes: 34 additions & 29 deletions packages/editor/src/components/editor/floating-action-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,20 @@ import { useFrame } from '@react-three/fiber'
import { useCallback, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow'
import { useReducedMotion } from '../../hooks/use-reduced-motion'
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
import {
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement'
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
import useInteractionScope, {
useActiveHandleDrag,
Expand Down Expand Up @@ -207,26 +212,9 @@ function collectQuickActionNodes(
): Record<AnyNodeId, AnyNode> | null {
if (!selectedId) return null
const selected = nodes[selectedId as AnyNodeId]
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null

const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
const add = (id: string | null | undefined) => {
if (!id) return
const node = nodes[id as AnyNodeId]
if (node) collected[node.id as AnyNodeId] = node
}
const addChildren = (node: AnyNode | undefined) => {
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
add(childId)
}
}

add(selected.parentId ?? null)
addChildren(selected)
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
addChildren(parent)

return collected
const def = selected ? nodeRegistry.get(selected.type) : undefined
if (!def?.quickActions) return null
return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope)
}

// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
Expand Down Expand Up @@ -296,6 +284,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
}

export function FloatingActionMenu() {
const reducedMotion = useReducedMotion()
const selectedIds = useViewer((s) => s.selection.selectedIds)
const updateNode = useScene((s) => s.updateNode)
const mode = useEditor((s) => s.mode)
Expand Down Expand Up @@ -513,7 +502,8 @@ export function FloatingActionMenu() {
e.stopPropagation()
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as any)
const sceneNodes = useScene.getState().nodes
setMovingNode(resolveMoveActionNode(node, sceneNodes) as any)
setSelection({ selectedIds: [] })
},
[node, setMovingNode, setSelection],
Expand Down Expand Up @@ -775,9 +765,15 @@ export function FloatingActionMenu() {
)

const handleQuickAction = useCallback(
(action: NodeQuickAction) => (e: React.MouseEvent) => {
(action: NodeQuickAction) => (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (!node || action.disabled) return
if (!node) return
if (action.disabled) {
if (action.blockedFeedback) {
playBlockedQuickActionFeedback(e.currentTarget, reducedMotion)
}
return
}
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
const result =
action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
Expand All @@ -787,7 +783,7 @@ export function FloatingActionMenu() {
sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
}
},
[node, setSelection],
[node, reducedMotion, setSelection],
)

if (
Expand Down Expand Up @@ -850,18 +846,27 @@ export function FloatingActionMenu() {
>
{quickActions.map((action) => (
<button
aria-disabled={action.disabled || undefined}
aria-label={action.title ?? action.label}
className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
disabled={action.disabled}
className={cn(
'tooltip-trigger flex items-center rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground',
action.disabled &&
'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
)}
disabled={action.disabled && !action.blockedFeedback}
key={action.id}
onClick={handleQuickAction(action)}
title={action.title ?? action.label}
type="button"
>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
<QuickActionIcon action={action} />
<span className="flex items-center gap-1.5" data-quick-action-feedback>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
<QuickActionIcon action={action} />
</span>
<span className="whitespace-nowrap leading-none" data-quick-action-label>
{action.label}
</span>
</span>
<span className="whitespace-nowrap leading-none">{action.label}</span>
</button>
))}
</div>
Expand Down
Loading
Loading