Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 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
fc58e4f
feat: improve roof placement and hosted extensions
sudhir9297 Aug 24, 2026
91ef1ec
fix: preserve curved lean-to roof connections
sudhir9297 Aug 24, 2026
af98936
fix roof and dormer editing behavior
sudhir9297 Aug 24, 2026
6ae92c1
feat: improve roof openings and lean-to canopies
sudhir9297 Aug 25, 2026
dc6b4a9
Fix centered lean-to placement
sudhir9297 Aug 25, 2026
c03ab30
Align placement previews with architecture
sudhir9297 Aug 25, 2026
40c6c8a
Merge canonical main into placement preview branch
sudhir9297 Aug 25, 2026
1f790d3
Fix dormer window placement grid orientation
sudhir9297 Aug 25, 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
78 changes: 41 additions & 37 deletions apps/editor/components/build-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { nodeRegistry } from '@pascal-app/core'
import { nodeRegistry, useRegistryVersion } from '@pascal-app/core'
import {
type FloorplanMode,
getFloorplanNodeExtension,
Expand All @@ -13,13 +13,14 @@ import {
} from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/toolbar-tooltip'
import { getActiveRoofFeatureId } from '@/lib/build-tab-state'
import { cn } from '@/lib/utils'

/**
Expand Down Expand Up @@ -169,10 +170,36 @@ function activateTerrainSculptMode(): void {
useEditor.getState().setMode('terrain-sculpt')
}

type RoofFeature = { kind: string; label: string; iconSrc: string }
type RoofFeature = {
id: string
label: string
iconSrc: string
kind?: string
}

const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp'

function collectRoofFeatures(): RoofFeature[] {
const features: RoofFeature[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if (
def.capabilities.roofAccessory === undefined &&
def.presentation?.paletteGroup !== 'roof-features'
) {
continue
}
if (def.capabilities.wallOpeningPlacement) continue
const icon = def.presentation?.icon
features.push({
id: kind,
kind,
label: def.presentation?.label ?? kind,
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
})
}
return features
}

/**
* Roof accessories and extensions surfaced under the Roof tile. Unlike the
* community editor these aren't DB presets — each is a registry kind, either
Expand All @@ -181,13 +208,13 @@ const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp'
* populated during app bootstrap. Label + icon come from `presentation`;
* non-url icons fall back to the roof icon.
*/
function activateRoofFeatureTool(kind: string): void {
function activateRoofFeatureTool(feature: RoofFeature): void {
const ed = useEditor.getState()
ed.setPhase('structure')
ed.setStructureLayer('elements')
ed.setCatalogCategory(null)
ed.setMode('build')
ed.setTool(kind)
if (feature.kind) ed.setTool(feature.kind)
}

/**
Expand All @@ -211,15 +238,13 @@ export function BuildTab() {
const floorplanMode = useFloorplanMode((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
useRegistryVersion()
const registryReady = useSyncExternalStore(
subscribeToClientMount,
() => true,
() => false,
)
const buildTypes = useMemo(
() => (registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES),
[floorplanMode, registryReady],
)
const buildTypes = registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES

// The fitting / follow tools are armed from a segment's panel, not a grid
// tile — keep the segment tile lit so the panel (and the way back) stays
Expand All @@ -242,38 +267,16 @@ export function BuildTab() {

// Read at render time (not module scope): the registry is populated by the
// app bootstrap, so enumerating earlier would race it and see no kinds.
const roofFeatures = useMemo<RoofFeature[]>(() => {
if (!registryReady) return []
const features: RoofFeature[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if (
def.capabilities.roofAccessory === undefined &&
def.presentation?.paletteGroup !== 'roof-features'
) {
continue
}
// Door / window declare `roofAccessory` for the wall-face cut but
// already have their own Build tiles — listing them here too
// would duplicate the entry under Roof → Features.
if (def.capabilities.wallOpeningPlacement) continue
const icon = def.presentation?.icon
features.push({
kind,
label: def.presentation?.label ?? kind,
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
})
}
return features
}, [registryReady])
const roofFeatures = registryReady ? collectRoofFeatures() : []

// Tile highlight derives from the single source of truth (the active tool /
// mode), never a separate local selection — so keyboard shortcuts and panel
// clicks always agree on which tile is lit.
// The roof Features sub-grid arms roof-accessory tools (skylight, chimney,
// …); keep the Roof tile lit (and its panel open) while any of them is the
// active tool, the same way MEP stays lit for its sub-grid tools.
const isRoofFeatureActive =
mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool)
const activeRoofFeatureId = getActiveRoofFeatureId(roofFeatures, activeTool)
const isRoofFeatureActive = mode === 'build' && activeRoofFeatureId !== null
const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)

const isTypeActive = (type: BuildType) => {
Expand Down Expand Up @@ -377,11 +380,12 @@ export function BuildTab() {
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
>
{roofFeatures.map((feature) => {
const active = mode === 'build' && activeTool === feature.kind
const active = mode === 'build' && feature.id === activeRoofFeatureId
return (
<Tooltip key={feature.kind}>
<Tooltip key={feature.id}>
<TooltipTrigger asChild>
<button
aria-pressed={active}
className={cn(
'group relative flex aspect-square items-center justify-center rounded-xl p-1 transition-all duration-200',
active
Expand All @@ -390,7 +394,7 @@ export function BuildTab() {
)}
onClick={() => {
triggerSFX('sfx:menu-click')
activateRoofFeatureTool(feature.kind)
activateRoofFeatureTool(feature)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
Expand Down
23 changes: 23 additions & 0 deletions apps/editor/lib/build-tab-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, test } from 'bun:test'
import { getActiveRoofFeatureId, type RoofFeatureIdentity } from './build-tab-state'

const FEATURES: RoofFeatureIdentity[] = [
{ id: 'lean-to-extension', kind: 'lean-to-extension' },
{ id: 'skylight', kind: 'skylight' },
]

describe('roof feature selection', () => {
test('does not select every accessory for the plain roof tool', () => {
expect(getActiveRoofFeatureId(FEATURES, 'roof')).toBeNull()
})

test('selects exactly the matching accessory', () => {
expect(getActiveRoofFeatureId(FEATURES, 'lean-to-extension')).toBe('lean-to-extension')
})

test('ignores missing tool identities', () => {
const malformed = FEATURES.map(({ id }) => ({ id }))
expect(getActiveRoofFeatureId(malformed, undefined)).toBeNull()
expect(getActiveRoofFeatureId(malformed, 'skylight')).toBeNull()
})
})
12 changes: 12 additions & 0 deletions apps/editor/lib/build-tab-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type RoofFeatureIdentity = {
id: string
kind?: string
}

export function getActiveRoofFeatureId(
features: readonly RoofFeatureIdentity[],
activeTool: string | null | undefined,
): string | null {
if (!activeTool) return null
return features.find((feature) => feature.kind === activeTool)?.id ?? null
}
10 changes: 10 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ export {
} from './hooks/spatial-grid/support-host-patch'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
export {
type ConicalRoofInvalidPlacement,
type ConicalRoofLevelPlacement,
type ConicalRoofPlacement,
type ConicalRoofSurfacePlacement,
type ResolveConicalRoofPlacementInput,
type RoofPlacementMode,
resolveConicalRoofPlacement,
} from './lib/conical-roof-placement'
export {
clampDoorOperationState,
getDoorRenderOpenAmount,
Expand Down Expand Up @@ -144,6 +153,7 @@ export {
type RoofPlanSegment,
roofOverlapEntryOwns,
roofPlanBoundsOverlap,
roofPlanOverlapEntryOwns,
} from './lib/roof-overlap'
export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy'
export {
Expand Down
138 changes: 138 additions & 0 deletions packages/core/src/lib/conical-roof-placement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// @ts-expect-error - bun:test is provided by the Bun runtime; core does not depend on @types/bun.
import { describe, expect, test } from 'bun:test'
import { LevelNode, RoofNode, RoofSegmentNode } from '../schema'
import { resolveConicalRoofPlacement } from './conical-roof-placement'

function sceneWithHost() {
const level = LevelNode.parse({
id: 'level_host',
children: ['roof_host'],
})
const roof = RoofNode.parse({
id: 'roof_host',
parentId: level.id,
position: [2, 1, 3],
children: ['rseg_host'],
})
const segment = RoofSegmentNode.parse({
id: 'rseg_host',
parentId: roof.id,
roofType: 'gable',
width: 10,
depth: 8,
wallHeight: 2,
pitch: 45,
})
return {
level,
roof,
segment,
nodes: {
[level.id]: level,
[roof.id]: roof,
[segment.id]: segment,
},
}
}

describe('conical roof placement', () => {
test('ground mode creates a level-supported roof at the drawn center', () => {
const { level, nodes } = sceneWithHost()
const placement = resolveConicalRoofPlacement({
nodes,
levelId: level.id,
center: [2, 3],
radius: 1,
curbHeight: 0.5,
mode: 'ground',
})

expect(placement).toEqual({
valid: true,
kind: 'level',
position: [2, 0, 3],
wallHeight: 0.5,
support: { kind: 'level' },
})
})

test('auto mode mounts a fully contained circle on the highest roof surface', () => {
const { level, roof, segment, nodes } = sceneWithHost()
const placement = resolveConicalRoofPlacement({
nodes,
levelId: level.id,
center: [2, 3],
radius: 1,
curbHeight: 0.5,
mode: 'auto',
})

expect(placement.valid).toBe(true)
if (!(placement.valid && placement.kind === 'roof')) throw new Error('expected roof placement')
expect(placement.hostRoofId).toBe(roof.id)
expect(placement.position[0]).toBe(2)
expect(placement.position[2]).toBe(3)
expect(placement.wallHeight).toBeGreaterThan(0.5)
expect(placement.support).toEqual({
kind: 'roof',
roofSegmentId: segment.id,
localPosition: [0, 0],
curbHeight: 0.5,
})
})

test('roof mode rejects a circle that has no complete roof support', () => {
const { level, nodes } = sceneWithHost()
const placement = resolveConicalRoofPlacement({
nodes,
levelId: level.id,
center: [20, 20],
radius: 1,
curbHeight: 0.5,
mode: 'roof',
})

expect(placement).toEqual({ valid: false, reason: 'no-roof-support' })
})

test('auto mode falls back to the level when no roof supports the circle', () => {
const { level, nodes } = sceneWithHost()
const placement = resolveConicalRoofPlacement({
nodes,
levelId: level.id,
center: [20, 20],
radius: 1,
curbHeight: 0.75,
mode: 'auto',
})

expect(placement).toEqual({
valid: true,
kind: 'level',
position: [20, 0, 20],
wallHeight: 0.75,
support: { kind: 'level' },
})
})

test('roof schema preserves the optional surface attachment and parses legacy roofs', () => {
const legacy = RoofNode.parse({ id: 'roof_legacy' })
expect(legacy.support).toEqual({ kind: 'level' })

const mounted = RoofNode.parse({
id: 'roof_mounted',
support: {
kind: 'roof',
roofSegmentId: 'rseg_host',
localPosition: [1.25, -0.5],
curbHeight: 0.4,
},
})
expect(mounted.support).toEqual({
kind: 'roof',
roofSegmentId: 'rseg_host',
localPosition: [1.25, -0.5],
curbHeight: 0.4,
})
})
})
Loading
Loading