Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 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
e59c349
feat: add paintable roof accessory materials
sudhir9297 Aug 20, 2026
a4003c8
feat(editor): rotate roof direction while drawing
sudhir9297 Aug 20, 2026
930b677
Fix roof face consistency and editing flow
sudhir9297 Aug 20, 2026
d03c9ae
feat: add automatic open roof valleys
sudhir9297 Aug 20, 2026
6818b7c
fix: complete roof intersection trimming
sudhir9297 Aug 20, 2026
9d72e22
refactor: align roof features with architecture
sudhir9297 Aug 20, 2026
11878fa
test: align roof accessory paint expectations
sudhir9297 Aug 20, 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: 15 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,21 @@ export {
polygonsOverlap,
segmentsIntersect,
} from './lib/polygon-relations'
export {
type Point2D as PolygonBooleanPoint2D,
subtractPolygonsFromPolygon,
unionPolygons,
} from './lib/polygon-union'
export {
compareRoofOverlapIdentity,
getRoofPlanBounds,
type RoofOverlapEntry,
type RoofPlan,
type RoofPlanBounds,
type RoofPlanSegment,
roofOverlapEntryOwns,
roofPlanBoundsOverlap,
} from './lib/roof-overlap'
export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy'
export {
getRenderableSlabPolygon,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { type Point2D, subtractPolygonsFromPolygon, unionPolygons } from './polygon-union'
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/lib/roof-overlap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// @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 { getRoofPlanBounds, roofOverlapEntryOwns, roofPlanBoundsOverlap } from './roof-overlap'

describe('roof overlap', () => {
test('larger segments own intersections with stable ID tie-breaking', () => {
const current = { roofId: 'roof_b', segmentId: 'seg_b', width: 4, depth: 4 }
expect(
roofOverlapEntryOwns({ ...current, roofId: 'roof_a', segmentId: 'seg_a' }, current),
).toBe(true)
expect(roofOverlapEntryOwns({ ...current, width: 5 }, current)).toBe(true)
expect(roofOverlapEntryOwns({ ...current, width: 3 }, current)).toBe(false)
})

test('computes rotated world bounds and rejects distant roofs', () => {
const bounds = getRoofPlanBounds({
position: [10, 0, 4],
rotation: Math.PI / 2,
segments: [{ position: [0, 0, 0], rotation: 0, width: 6, depth: 2 }],
})!
expect(bounds.minX).toBeCloseTo(9)
expect(bounds.maxX).toBeCloseTo(11)
expect(bounds.minZ).toBeCloseTo(1)
expect(bounds.maxZ).toBeCloseTo(7)
expect(roofPlanBoundsOverlap(bounds, { minX: 10, minZ: 6, maxX: 12, maxZ: 8 })).toBe(true)
expect(roofPlanBoundsOverlap(bounds, { minX: 20, minZ: 20, maxX: 22, maxZ: 22 })).toBe(false)
})
})
95 changes: 95 additions & 0 deletions packages/core/src/lib/roof-overlap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
export type RoofOverlapEntry = {
roofId: string
segmentId: string
width: number
depth: number
}

export type RoofPlanBounds = {
minX: number
minZ: number
maxX: number
maxZ: number
}

export type RoofPlanSegment = {
position: readonly [number, number, number]
rotation?: number
width: number
depth: number
}

export type RoofPlan = {
position: readonly [number, number, number]
rotation?: number
segments: readonly RoofPlanSegment[]
}

export function compareRoofOverlapIdentity(a: RoofOverlapEntry, b: RoofOverlapEntry): number {
const roofOrder = a.roofId.localeCompare(b.roofId)
return roofOrder !== 0 ? roofOrder : a.segmentId.localeCompare(b.segmentId)
}

export function roofOverlapEntryOwns(
candidate: RoofOverlapEntry,
current: RoofOverlapEntry,
epsilon = 1e-6,
): boolean {
const candidateArea = candidate.width * candidate.depth
const currentArea = current.width * current.depth
return (
candidateArea > currentArea + epsilon ||
(Math.abs(candidateArea - currentArea) <= epsilon &&
compareRoofOverlapIdentity(candidate, current) < 0)
)
}

export function getRoofPlanBounds(roof: RoofPlan): RoofPlanBounds | null {
if (roof.segments.length === 0) return null
const roofRotation = roof.rotation ?? 0
const roofCos = Math.cos(roofRotation)
const roofSin = Math.sin(roofRotation)
const bounds: RoofPlanBounds = {
minX: Number.POSITIVE_INFINITY,
minZ: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxZ: Number.NEGATIVE_INFINITY,
}

for (const segment of roof.segments) {
const segmentRotation = segment.rotation ?? 0
const segmentCos = Math.cos(segmentRotation)
const segmentSin = Math.sin(segmentRotation)
const halfWidth = Math.max(0, segment.width) / 2
const halfDepth = Math.max(0, segment.depth) / 2
for (const [x, z] of [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
] as const) {
const roofX = segment.position[0] + x * segmentCos + z * segmentSin
const roofZ = segment.position[2] - x * segmentSin + z * segmentCos
const worldX = roof.position[0] + roofX * roofCos + roofZ * roofSin
const worldZ = roof.position[2] - roofX * roofSin + roofZ * roofCos
bounds.minX = Math.min(bounds.minX, worldX)
bounds.minZ = Math.min(bounds.minZ, worldZ)
bounds.maxX = Math.max(bounds.maxX, worldX)
bounds.maxZ = Math.max(bounds.maxZ, worldZ)
}
}
return bounds
}

export function roofPlanBoundsOverlap(
a: RoofPlanBounds,
b: RoofPlanBounds,
epsilon = 1e-6,
): boolean {
return !(
a.maxX < b.minX - epsilon ||
b.maxX < a.minX - epsilon ||
a.maxZ < b.minZ - epsilon ||
b.maxZ < a.minZ - epsilon
)
}
10 changes: 6 additions & 4 deletions packages/core/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export {
getBlockFaceNormal,
inspectBlockTopology,
} from './nodes/block'
export { BoxVentNode } from './nodes/box-vent'
export { BoxVentMaterialRole, BoxVentNode } from './nodes/box-vent'
export { BuildingNode } from './nodes/building'
export { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
export { CeilingNode } from './nodes/ceiling'
Expand Down Expand Up @@ -94,7 +94,7 @@ export {
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './nodes/construction-dimension'
export { CupolaNode } from './nodes/cupola'
export { CupolaMaterialRole, CupolaNode } from './nodes/cupola'
export {
DoorNode,
DoorSegment,
Expand Down Expand Up @@ -122,7 +122,7 @@ export {
ElevatorNode,
ElevatorShaftStyle,
} from './nodes/elevator'
export { EyebrowVentNode } from './nodes/eyebrow-vent'
export { EyebrowVentMaterialRole, EyebrowVentNode } from './nodes/eyebrow-vent'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode, GuideScaleReference } from './nodes/guide'
export {
Expand Down Expand Up @@ -225,6 +225,7 @@ export {
} from './nodes/roof-segment'
export type {
DutchRoofShapeMetrics,
RoofShapeEaveSide,
RoofShapeFaceVertex,
RoofShapeInsets,
RoofShapeRatios,
Expand All @@ -233,6 +234,7 @@ export {
getDutchEndSlopeFaces,
getDutchRoofShapeMetrics,
getRoofModuleFaces,
getRoofShapeEaveSides,
getRoofShapeInsets,
getRoofShapeRatios,
} from './nodes/roof-segment-shape'
Expand Down Expand Up @@ -278,7 +280,7 @@ export {
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { StructuralGridNode } from './nodes/structural-grid'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { TurbineVentNode } from './nodes/turbine-vent'
export { TurbineVentMaterialRole, TurbineVentNode } from './nodes/turbine-vent'
export type {
WallBandSurfaceSlotId,
WallFaceBand,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schema/material.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const MaterialTarget = z.enum([
'cupola',
'eyebrow-vent',
'gutter',
'downspout',
])
export type MaterialTarget = z.infer<typeof MaterialTarget>

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/schema/nodes/box-vent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'

export const BoxVentMaterialRole = z.enum(['base', 'top'])
export type BoxVentMaterialRole = z.infer<typeof BoxVentMaterialRole>

export const BoxVentNode = BaseNode.extend({
id: objectId('bvent'),
type: nodeType('box-vent'),

material: MaterialSchema.optional(),
slots: z.record(z.string(), z.string()).optional(),
// Default to the white preset so newly-placed vents read as clean
// painted metal — and so the paint inspector shows "White" as the
// current selection instead of an empty "no material" state, which
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/schema/nodes/cupola.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'

export const CupolaMaterialRole = z.enum(['base', 'body', 'roof'])
export type CupolaMaterialRole = z.infer<typeof CupolaMaterialRole>

export const CupolaNode = BaseNode.extend({
id: objectId('cupola'),
type: nodeType('cupola'),

material: MaterialSchema.optional(),
slots: z.record(z.string(), z.string()).optional(),
// Default to the white preset so a freshly-placed cupola reads as clean
// painted metal and the paint inspector shows "White" (matches box-vent).
materialPreset: z.string().default('preset-white'),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schema/nodes/downspout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const DownspoutNode = BaseNode.extend({
type: nodeType('downspout'),

material: MaterialSchema.optional(),
slots: z.record(z.string(), z.string()).optional(),
// Match the gutter family default — paint inspector reads "White"
// instead of "no material" on a freshly placed downspout.
materialPreset: z.string().default('preset-white'),
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/schema/nodes/eyebrow-vent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'

export const EyebrowVentMaterialRole = z.enum(['hood', 'front'])
export type EyebrowVentMaterialRole = z.infer<typeof EyebrowVentMaterialRole>

export const EyebrowVentNode = BaseNode.extend({
id: objectId('eyebrow-vent'),
type: nodeType('eyebrow-vent'),

material: MaterialSchema.optional(),
slots: z.record(z.string(), z.string()).optional(),
// Default to the white preset so a freshly-placed vent reads as clean
// painted metal and the paint inspector shows "White" (matches box-vent).
materialPreset: z.string().default('preset-white'),
Expand Down
12 changes: 3 additions & 9 deletions packages/core/src/schema/nodes/gutter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { normalizeRoofSegmentTrim, type RoofSegmentNode } from './roof-segment'
import { getRoofShapeEaveSides } from './roof-segment-shape'

const MIN_DEFAULT_GUTTER_LENGTH_M = 0.2
const DEFAULT_GUTTER_GENERATOR = 'default-gutter'
Expand Down Expand Up @@ -50,6 +51,7 @@ export const GutterNode = BaseNode.extend({
type: nodeType('gutter'),

material: MaterialSchema.optional(),
slots: z.record(z.string(), z.string()).optional(),
// White preset by default — matches the rest of the roof accessory
// family (box-vent / ridge-vent) so the paint inspector reads as
// "White" instead of "no material" on a freshly-placed gutter.
Expand Down Expand Up @@ -143,15 +145,7 @@ export function computeGutterEaveY(
}

function getDefaultGutterSides(segment: RoofSegmentNode): GutterEaveSide[] {
switch (segment.roofType) {
case 'shed':
return ['+Z']
case 'gable':
case 'gambrel':
return ['+Z', '-Z']
default:
return ['+Z', '-Z', '+X', '-X']
}
return getRoofShapeEaveSides(segment.roofType)
}

function getGutterEnvelope(segment: RoofSegmentNode) {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/schema/nodes/roof-segment-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ export type RoofShapeFaceVertex = {
z: number
}

export type RoofShapeEaveSide = '+X' | '-X' | '+Z' | '-Z'

export function getRoofShapeEaveSides(type: RoofType): RoofShapeEaveSide[] {
switch (type) {
case 'shed':
return ['+Z']
case 'gable':
case 'gambrel':
return ['+Z', '-Z']
default:
return ['+Z', '-Z', '+X', '-X']
}
}

export type RoofShapeInsets = {
iF?: number
iB?: number
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/schema/nodes/turbine-vent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'

export const TurbineVentMaterialRole = z.enum(['base', 'head'])
export type TurbineVentMaterialRole = z.infer<typeof TurbineVentMaterialRole>

export const TurbineVentNode = BaseNode.extend({
id: objectId('tvent'),
type: nodeType('turbine-vent'),

material: MaterialSchema.optional(),
slots: z.record(z.string(), z.string()).optional(),
// Default to the white preset so a freshly-placed turbine reads as
// clean painted/galvanised metal and the paint inspector shows "White"
// as the current selection (matches box-vent's reasoning).
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/store/use-scene-wall-slot-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,35 @@ describe('procedural kind surface-material → slots migration', () => {
expect(slab.slots).toBeUndefined()
expect(Object.keys(useScene.getState().materials)).toHaveLength(0)
})

test('roof accessory role materials migrate to their matching slots', () => {
useScene.getState().setScene(
sceneWithNode({
type: 'box-vent',
baseMaterialPreset: 'library:metal-steel',
topMaterialPreset: 'library:metal-copper',
}),
['site_test'] as never,
)

const vent = (useScene.getState().nodes as Record<string, SlottedNode>).node_test!
expect(vent.slots).toEqual({
base: 'library:metal-steel',
top: 'library:metal-copper',
})
expect((vent as { baseMaterialPreset?: unknown }).baseMaterialPreset).toBeUndefined()
expect((vent as { topMaterialPreset?: unknown }).topMaterialPreset).toBeUndefined()
})

test('gutter and downspout legacy paint migrates to the surface slot', () => {
for (const type of ['gutter', 'downspout'] as const) {
useScene
.getState()
.setScene(sceneWithNode({ type, materialPreset: 'library:metal-steel' }), [
'site_test',
] as never)
const node = (useScene.getState().nodes as Record<string, SlottedNode>).node_test!
expect(node.slots).toEqual({ surface: 'library:metal-steel' })
}
})
})
Loading
Loading