Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 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
00d84d5
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 19, 2026
2c2dabc
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 22, 2026
29f914f
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 22, 2026
1cbf910
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 23, 2026
8f59da7
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 24, 2026
650abd6
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 27, 2026
1f8dc8b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 28, 2026
7c4b791
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 31, 2026
ef7184f
Merge branch 'main' of github.com:sudhir9297/editor; branch 'main' of…
sudhir9297 Aug 10, 2026
c92a376
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Aug 12, 2026
da49781
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Aug 19, 2026
0f66e8f
Merge branch 'main' of github.com:sudhir9297/editor; branch 'main' of…
sudhir9297 Aug 21, 2026
0928a63
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Aug 24, 2026
d9f8be9
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Aug 31, 2026
f944cfe
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Sep 1, 2026
32810ca
Enhance modular cabinet editing and validation
sudhir9297 Sep 1, 2026
178e790
Complete cabinet interaction enhancements
sudhir9297 Sep 1, 2026
e197ae0
Fix architecture review findings
sudhir9297 Sep 1, 2026
bcc93f4
fix cabinet wall opening awareness during moves
sudhir9297 Sep 2, 2026
dd59e74
feat cabinet exact dimension placement
sudhir9297 Sep 2, 2026
ccc319d
feat cabinet run width equalization
sudhir9297 Sep 2, 2026
fff2166
feat cabinet run array duplication
sudhir9297 Sep 2, 2026
9d7e9c7
feat add wall cabinet height presets
sudhir9297 Sep 2, 2026
3326bdb
chore remove swing check and hinge quick action
sudhir9297 Sep 2, 2026
1d2af0d
fix cabinet preview architecture findings
sudhir9297 Sep 2, 2026
48add69
fix(cabinet): preview linked L runs during width resize
sudhir9297 Sep 2, 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
15 changes: 13 additions & 2 deletions packages/core/src/registry/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export type HandleAxis = 'x' | 'y' | 'z'

export type HandleAnchor = 'center' | 'min' | 'max'

/** Keyboard modifiers captured for a handle-resize tick. */
export type HandleDragModifiers = {
readonly altKey: boolean
}

/** 3D position + rotation of the arrow in its portal target's local space. */
export type HandlePlacement<N> = {
/**
Expand Down Expand Up @@ -130,7 +135,12 @@ export type LinearResizeHandle<N> = {
axis: HandleAxis
anchor: HandleAnchor
currentValue: (node: N) => number
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
apply: (
node: N,
newValue: number,
sceneApi: SceneApi,
modifiers?: HandleDragModifiers,
) => 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
Expand All @@ -141,6 +151,7 @@ export type LinearResizeHandle<N> = {
node: N,
newValue: number,
sceneApi: SceneApi,
modifiers?: HandleDragModifiers,
) => ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>
/** Optional live-scene visibility gate for context-dependent arrows. */
visible?: (node: N, sceneApi: SceneApi) => boolean
Expand All @@ -151,7 +162,7 @@ export type LinearResizeHandle<N> = {
* final write here to fan the resize out to siblings / parents while keeping
* the handle UI generic.
*/
commit?: (node: N, patch: Partial<N>, sceneApi: SceneApi) => void
commit?: (node: N, patch: Partial<N>, sceneApi: SceneApi, modifiers?: HandleDragModifiers) => void
/**
* Optional per-tick hook fired while this handle is being dragged, with the
* live (in-progress, override-merged) node. A pure side-channel for transient
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
HandleAnchor,
HandleAxis,
HandleDescriptor,
HandleDragModifiers,
HandleList,
HandlePlacement,
HandlePortal,
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1972,6 +1972,18 @@ export type MovableConfig = {
* `groupMoveSnap` contract so existing v1 plugins remain valid.
*/
groupMoveSnapPose?: (args: GroupMoveSnapArgs) => GroupMoveSnapResult | null
/**
* Optional kind-owned validity check for the final planar drag pose. This
* complements `floorPlaced` collision checks for constraints that depend
* on other scene geometry, such as a cabinet crossing a wall opening.
*/
isValidPosition?: (args: {
node: AnyNode
position: readonly [number, number, number]
rotation: number
levelId: AnyNodeId | null
nodes: Readonly<Record<string, AnyNode>>
}) => boolean
/**
* Kind-owned grid resolver for a planar move. Unlike scalar grid snapping,
* this receives the complete candidate pose so a kind can snap a visible
Expand Down Expand Up @@ -2024,6 +2036,24 @@ export type MovableParentFrame = {
snappedLocal: readonly [number, number, number],
nodes: Readonly<Record<string, AnyNode>>,
) => ParentFrameSnapMatch[]
/** Optional kind-owned live patches for derived nodes that follow the move. */
previewOverrides?: (args: {
node: AnyNode
parent: AnyNode
position: readonly [number, number, number]
sceneApi: SceneApi
}) => ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>
/**
* Optional live collision check for a child moving in the parent frame.
* The generic move tool uses this to colour the drag bounds and reject an
* invalid drop; the kind owns the actual domain rule.
*/
isValidPosition?: (args: {
node: AnyNode
parent: AnyNode
position: readonly [number, number, number]
nodes: Readonly<Record<string, AnyNode>>
}) => boolean
/**
* Called after a move of the child commits, with the LIVE (post-commit)
* child and parent. Lets the kind run derived-state maintenance the
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/schema/nodes/cabinet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ const cabinetBoxFields = {
frontThickness: z.number().min(0.01).max(0.05).default(0.018),
frontGap: z.number().min(0.001).max(0.02).default(0.003),
frontStyle: CabinetFrontStyleSchema.default('slab'),
// Fridge-only: replace the appliance door face with a cabinet-matched panel.
panelReady: z.boolean().default(false),
handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'),
handlePosition: z.enum(['auto', 'top', 'center']).default('auto'),
frontOverlay: z.enum(['full', 'inset']).default('full'),
Expand Down Expand Up @@ -139,6 +141,8 @@ export const CabinetNode = BaseNode.extend({
.optional(),
// Countertop material dropping to the floor on exposed run ends.
withWaterfall: z.boolean().default(false),
// Add matching decorative panels to ends that are not joined to another run.
withFinishedEnds: z.boolean().default(false),
...cabinetBoxFields,
}).describe('Parametric modular cabinet run node')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
emitter,
type FloorplanMoveTargetSession,
type GroupMoveSnapResult,
type MovableConfig,
nodeRegistry,
pauseSceneHistory,
resumeSceneHistory,
Expand Down Expand Up @@ -562,6 +563,9 @@ export function FloorplanRegistryMoveOverlay() {
let currentRotation = originalRotation
let lastSnapped: { point: [number, number]; rotation: number } | null = null
let dragAnchor: [number, number] | null = null
let lastPositionValid = true
let forcePlace = false
const movableValidityConfig = (def?.capabilities?.movable as MovableConfig | undefined) ?? null

// Footprint bounding box drawn around the dragged entry — the 2D
// counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
Expand All @@ -588,6 +592,7 @@ export function FloorplanRegistryMoveOverlay() {
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
const m = toMeters(event.clientX, event.clientY)
if (!m) return
forcePlace = event.altKey

// 1) Wall attachment gets the raw proposal before grid/alignment. If no
// attachment is available, fresh placement is absolute under the cursor
Expand Down Expand Up @@ -712,6 +717,24 @@ export function FloorplanRegistryMoveOverlay() {
relatedEntry.setAttribute('transform', transform)
}
boxEl.setAttribute('transform', transform)
const oldY = originalPosition[1]
lastPositionValid = movableValidityConfig?.isValidPosition
? movableValidityConfig.isValidPosition({
node: {
...movingNode,
position: [finalX, oldY, finalZ],
rotation: currentRotation,
} as AnyNode,
position: [finalX, oldY, finalZ],
rotation: currentRotation,
levelId:
(useViewer.getState().selection.levelId as AnyNodeId | null) ??
(movingNode.parentId as AnyNodeId | null) ??
null,
nodes: useScene.getState().nodes as Record<string, AnyNode>,
})
: true
boxEl.setAttribute('stroke', lastPositionValid || forcePlace ? '#22c55e' : '#ef4444')
lastSnapped = { point: [finalX, finalZ], rotation: currentRotation }
}

Expand All @@ -732,6 +755,15 @@ export function FloorplanRegistryMoveOverlay() {
: snapped.rotation
const rotationPatch = 'rotation' in movingNode ? { rotation } : {}
setMovingNodeOrigin('2d')
if (!lastPositionValid && !forcePlace) {
for (const relatedEntry of relatedEntries) {
relatedEntry.removeAttribute('transform')
}
useAlignmentGuides.getState().clear()
setMovingNode(null)
swallowNextClick()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2D drop ignores late Alt

Medium Severity

forcePlace is written only from pointermove altKey, and the drop path reads that stale flag instead of the pointer-up event. Holding Alt to force a cabinet over an opening after the last move still rejects the drop, while releasing Alt after a forced preview can still commit the invalid pose.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1d2af0d. Configure here.

}
let selectedId = movingNode.id as AnyNodeId
if (originalPath) {
// Polyline kinds: shift every point by the committed delta and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,14 @@ export function FloorplanDimensionRenderer({
stroke = geometry.stroke ?? '#334155',
annotationUnitsPerPoint,
renderMode = 'screen',
onSelect,
}: {
geometry: DimensionGeometry
sceneRotationDeg?: number
stroke?: string
annotationUnitsPerPoint?: number
renderMode?: FloorplanDimensionRenderMode
onSelect?: () => void
}): React.ReactElement | null {
const layout = computeArchitecturalDimensionLayout(
geometry,
Expand Down Expand Up @@ -200,7 +202,18 @@ export function FloorplanDimensionRenderer({
: undefined

return (
<g data-floorplan-dimension="" pointerEvents="none">
<g
data-floorplan-dimension=""
onClick={
onSelect
? (event) => {
event.stopPropagation()
onSelect()
}
: undefined
}
pointerEvents={onSelect ? 'auto' : 'none'}
>
<line
{...lineProps}
x1={layout.extensionStart[0]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { memo, useMemo } from 'react'
import { formatLinearMeasurement } from '../../../lib/measurements'
import usePlacementPreview from '../../../store/use-placement-preview'
import { useFloorplanRender } from '../floorplan-render-context'
import { useFloorplanRender, useFloorplanSceneRotation } from '../floorplan-render-context'
import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'

export interface FloorplanNodePreviewProps {
node: AnyNode
parentNode?: AnyNode | null
contextNodes?: AnyNode[]
opacity?: number
className?: string
selected?: boolean
Expand All @@ -33,6 +36,7 @@ export interface FloorplanNodePreviewProps {
export const FloorplanNodePreview = memo(function FloorplanNodePreview({
node,
parentNode = null,
contextNodes: previewContextNodes = [],
opacity = 0.5,
className,
selected = false,
Expand All @@ -53,6 +57,9 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
...(nodes as Record<string, AnyNode>),
[node.id]: node,
}
for (const previewNode of previewContextNodes) {
contextNodes[previewNode.id] = previewNode
}
if (parentNode) contextNodes[parentNode.id] = parentNode
const resolvedParent =
parentNode ?? (node.parentId ? (contextNodes[node.parentId] ?? null) : null)
Expand Down Expand Up @@ -90,7 +97,18 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
}

return (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(node, ctx)
}, [highlighted, hovered, moving, node, nodes, parentNode, renderContext, selected, unit])
}, [
highlighted,
hovered,
moving,
node,
nodes,
parentNode,
previewContextNodes,
renderContext,
selected,
unit,
])
if (!geometry) return null

return (
Expand All @@ -117,11 +135,42 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
const node = usePlacementPreview((s) => s.node)
const parentNode = usePlacementPreview((s) => s.parentNode)
const contextNodes = usePlacementPreview((s) => s.contextNodes)
const dimensions = usePlacementPreview((s) => s.dimensions)
const activeDimensionId = usePlacementPreview((s) => s.activeDimensionId)
const dimensionInput = usePlacementPreview((s) => s.dimensionInput)
const unit = useViewer((s) => s.unit)
const metricNotation = useViewer((s) => s.metricNotation)
const sceneRotationDeg = useFloorplanSceneRotation()
if (!node) return null

return (
<g data-floorplan-placement-preview>
<FloorplanNodePreview node={node} parentNode={parentNode} />
<FloorplanNodePreview contextNodes={contextNodes} node={node} parentNode={parentNode} />
<g data-floorplan-placement-dimensions>
{dimensions
.filter((dimension) => dimension.renderInFloorplan !== false)
.map((dimension) => (
<FloorplanDimensionRenderer
geometry={{
kind: 'dimension',
start: [dimension.start[0], dimension.start[2]],
end: [dimension.end[0], dimension.end[2]],
offsetNormal: dimension.offsetNormal,
offsetDistance: dimension.offsetDistance,
extensionOvershoot: 0.04,
text:
dimension.id === activeDimensionId && dimensionInput
? dimensionInput
: formatLinearMeasurement(dimension.value, unit, metricNotation),
stroke: '#6366f1',
}}
key={dimension.id}
onSelect={() => usePlacementPreview.getState().selectDimension(dimension.id)}
sceneRotationDeg={sceneRotationDeg}
/>
))}
</g>
</g>
)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
type AnyNode,
type AnyNodeId,
type HandleDragModifiers,
type LinearResizeHandle,
type SceneApi,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { replacePreviewOverrideIds } from './preview-overrides'

export function createLinearResizeDragBinding({
descriptor,
initialNode,
nodeId,
sceneApi,
initialModifiers,
}: {
descriptor: LinearResizeHandle<AnyNode>
initialNode: AnyNode
nodeId: AnyNodeId
sceneApi: SceneApi
initialModifiers: HandleDragModifiers
}) {
const overrideId = descriptor.overrideTarget?.(initialNode, sceneApi) ?? nodeId
let lastModifiers = initialModifiers
let previewOverrideIds = new Set<AnyNodeId>()

return {
overrideId,
commit: descriptor.commit
? (patch: Partial<AnyNode>) =>
descriptor.commit?.(initialNode, patch, sceneApi, lastModifiers)
: undefined,
apply(next: number, modifiers: HandleDragModifiers): Partial<AnyNode> {
lastModifiers = modifiers
const patch = descriptor.apply(initialNode, next, sceneApi, modifiers) as Partial<AnyNode>
const previewEntries = descriptor.previewOverrides?.(initialNode, next, sceneApi, modifiers)
if (!previewEntries) return patch

previewOverrideIds = replacePreviewOverrideIds(
previewOverrideIds,
previewEntries,
(previewId) => {
useLiveNodeOverrides.getState().clear(previewId)
useScene.getState().markDirty(previewId)
},
)
useLiveNodeOverrides
.getState()
.setMany(
previewEntries.map(([id, previewPatch]) => [id, previewPatch as Record<string, unknown>]),
)
for (const [previewId] of previewEntries) {
useScene.getState().markDirty(previewId)
}
return patch
},
clearPreview(): void {
for (const previewId of previewOverrideIds) {
useLiveNodeOverrides.getState().clear(previewId)
useScene.getState().markDirty(previewId)
}
previewOverrideIds = new Set()
},
}
}
Loading
Loading