Add procedural support column variants

This commit is contained in:
sudhir
2026-05-08 12:54:59 +05:30
parent e348ccdb08
commit 53393b7def
8 changed files with 2461 additions and 730 deletions
+1
View File
@@ -39,6 +39,7 @@ export {
ColumnShaftDetail,
ColumnShaftProfile,
ColumnStyle,
ColumnSupportStyle,
} from './nodes/column'
export { DoorNode, DoorSegment } from './nodes/door'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
+582
View File
@@ -56,6 +56,20 @@ export const ColumnRingPlacement = z.enum(['ends', 'even', 'top', 'bottom'])
export const ColumnCarvingPlacement = z.enum(['shaft', 'base', 'capital', 'all'])
export const ColumnSupportStyle = z.enum([
'vertical',
'a-frame',
'y-frame',
'v-frame',
'x-brace',
'k-brace',
'single-strut',
'tripod',
'trestle',
'portal-frame',
'box-frame',
])
export type ColumnStyle = z.infer<typeof ColumnStyle>
export type ColumnCrossSection = z.infer<typeof ColumnCrossSection>
export type ColumnShaftProfile = z.infer<typeof ColumnShaftProfile>
@@ -65,6 +79,7 @@ export type ColumnBaseStyle = z.infer<typeof ColumnBaseStyle>
export type ColumnCapitalStyle = z.infer<typeof ColumnCapitalStyle>
export type ColumnRingPlacement = z.infer<typeof ColumnRingPlacement>
export type ColumnCarvingPlacement = z.infer<typeof ColumnCarvingPlacement>
export type ColumnSupportStyle = z.infer<typeof ColumnSupportStyle>
export const ColumnNode = BaseNode.extend({
id: objectId('column'),
@@ -136,6 +151,12 @@ export const ColumnNode = BaseNode.extend({
lowerBandCarvingLevel: z.number().int().min(0).max(4).default(0),
dentilCount: z.number().int().min(0).max(48).default(0),
beadCount: z.number().int().min(0).max(64).default(0),
supportStyle: ColumnSupportStyle.default('vertical'),
braceWidth: z.number().positive().default(0.16),
braceDepth: z.number().positive().default(0.16),
braceBottomSpread: z.number().min(0.2).default(1.2),
braceTopSpread: z.number().min(0).default(0.12),
bracePlateEnabled: z.boolean().default(true),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
}).describe(dedent`
@@ -150,6 +171,7 @@ export const ColumnNode = BaseNode.extend({
- baseStyle/capitalStyle: procedural base and top treatment with tier/detail controls
- baseHeight/capitalHeight: bottom and top block proportions
- ring/flute/spiral/panel/lathe/carving fields: procedural detail controls
- supportStyle/brace fields: vertical column or procedural support assembly
`)
export const COLUMN_PRESETS = {
@@ -403,6 +425,566 @@ export const COLUMN_PRESETS = {
dentilCount: 0,
beadCount: 0,
},
aFrameSupport: {
label: 'A-Frame Support',
supportStyle: 'a-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
radius: 0.08,
width: 0.16,
depth: 0.16,
edgeSoftness: 0.012,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.012,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.16,
braceDepth: 0.16,
braceBottomSpread: 1.2,
braceTopSpread: 0.06,
bracePlateEnabled: false,
},
yFrameSupport: {
label: 'Y Support',
supportStyle: 'y-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.7,
radius: 0.08,
width: 0.16,
depth: 0.16,
edgeSoftness: 0.012,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.012,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.16,
braceDepth: 0.16,
braceBottomSpread: 0.2,
braceTopSpread: 1,
bracePlateEnabled: false,
},
vFrameSupport: {
label: 'V Support',
supportStyle: 'v-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
radius: 0.08,
width: 0.16,
depth: 0.16,
edgeSoftness: 0.012,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.012,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.16,
braceDepth: 0.16,
braceBottomSpread: 0.2,
braceTopSpread: 1,
bracePlateEnabled: false,
},
xBraceSupport: {
label: 'X Brace',
supportStyle: 'x-brace',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1,
braceTopSpread: 1,
bracePlateEnabled: false,
},
kBraceSupport: {
label: 'K Brace',
supportStyle: 'k-brace',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1,
braceTopSpread: 1,
bracePlateEnabled: false,
},
singleStrutSupport: {
label: 'Single Strut',
supportStyle: 'single-strut',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1,
braceTopSpread: 1,
bracePlateEnabled: false,
},
tripodSupport: {
label: 'Tripod Support',
supportStyle: 'tripod',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1.1,
braceTopSpread: 1.1,
bracePlateEnabled: false,
},
trestleSupport: {
label: 'Trestle Frame',
supportStyle: 'trestle',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1.2,
braceTopSpread: 1,
bracePlateEnabled: false,
},
portalFrameSupport: {
label: 'Portal Frame',
supportStyle: 'portal-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
radius: 0.08,
width: 0.16,
depth: 0.16,
edgeSoftness: 0.012,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.012,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.16,
braceDepth: 0.16,
braceBottomSpread: 1.4,
braceTopSpread: 0.2,
bracePlateEnabled: false,
},
boxFrameSupport: {
label: 'Box Frame',
supportStyle: 'box-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1.4,
braceTopSpread: 1,
bracePlateEnabled: false,
},
} as const satisfies Record<string, { label: string } & Partial<z.input<typeof ColumnNode>>>
export type ColumnPresetId = keyof typeof COLUMN_PRESETS
+2
View File
@@ -23,6 +23,7 @@ export const FenceNode = BaseNode.extend({
groundClearance: z.number().default(0),
edgeInset: z.number().default(0.015),
baseStyle: FenceBaseStyle.default('grounded'),
showInfill: z.boolean().default(true),
color: z.string().default('#ffffff'),
style: FenceStyle.default('slat'),
}).describe(
@@ -33,6 +34,7 @@ export const FenceNode = BaseNode.extend({
- height/thickness: overall fence dimensions in meters
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
- showInfill: whether to draw intermediate posts/slats between end posts
- color/style: visual appearance options
`,
)
@@ -1768,6 +1768,56 @@ function getRotatedRectanglePolygon(
function getColumnPlanFootprint(column: ColumnNode): Point2D[] {
const center = { x: column.position[0], y: column.position[2] }
if (
column.supportStyle === 'a-frame' ||
column.supportStyle === 'y-frame' ||
column.supportStyle === 'v-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
) {
const width = Math.max(
column.supportStyle === 'a-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
? (column.braceBottomSpread ?? 1.2)
: 0,
column.braceTopSpread ??
(column.supportStyle === 'y-frame' ||
column.supportStyle === 'v-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
? 1
: 0),
(column.braceWidth ?? column.width) * 2,
)
const depth = Math.max(
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'box-frame'
? (column.braceTopSpread ?? 1)
: 0,
column.braceDepth ?? column.depth,
0.08,
)
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
}
const shaftWidth =
column.crossSection === 'round' ||
column.crossSection === 'octagonal' ||
@@ -5742,6 +5792,12 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
const fenceGlowOpacity = isDeleteHovered ? 0.18 : isActive ? 0.22 : isHovered ? 0.14 : 0
const fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2'
const fenceStrokeWidth = isActive ? '2.6' : isHovered ? '2.35' : '2.05'
const showFenceInfill = fence.showInfill ?? true
const visibleMarkerFrames = showFenceInfill
? markerFrames
: markerFrames.filter(
(_, markerIndex) => markerIndex === 0 || markerIndex === markerFrames.length - 1,
)
const privacyMarkerWidth = clamp(fence.postSize * 0.58, 0.038, 0.068)
const privacyMarkerHeight = clamp(
Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4),
@@ -5792,7 +5848,7 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
strokeWidth={fenceStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
{markerFrames.map(({ angleDeg, point }, markerIndex) => {
{visibleMarkerFrames.map(({ angleDeg, point }, markerIndex) => {
const svgPoint = toSvgPoint(point)
if (fence.style === 'privacy') {
@@ -15,6 +15,7 @@ import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
const SELECT_CLASS =
@@ -83,6 +84,7 @@ function presetUpdates(presetId: ColumnPresetId): Partial<ColumnNode> {
const { label, ...preset } = COLUMN_PRESETS[presetId]
return {
name: label,
supportStyle: 'supportStyle' in preset ? preset.supportStyle : 'vertical',
...preset,
}
}
@@ -199,6 +201,18 @@ export function ColumnPanel() {
if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
const shaftProfile = node.shaftProfile ?? 'straight'
const supportStyle = node.supportStyle ?? 'vertical'
const isBraceSupport =
supportStyle === 'a-frame' ||
supportStyle === 'y-frame' ||
supportStyle === 'v-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'portal-frame' ||
supportStyle === 'box-frame'
return (
<PanelWrapper icon="/icons/column.png" onClose={handleClose} title={node.name || 'Column'} width={300}>
@@ -223,53 +237,115 @@ export function ColumnPanel() {
<PanelSection title="Shape">
<select
className={SELECT_CLASS}
onChange={(event) => handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })}
value={node.crossSection}
onChange={(event) => {
const nextSupportStyle = event.target.value as ColumnNode['supportStyle']
handleUpdate({
supportStyle: nextSupportStyle,
...(nextSupportStyle !== 'vertical'
? {
crossSection: 'rectangular',
width: node.braceWidth ?? node.width,
depth: node.braceDepth ?? node.depth,
baseStyle: 'none',
capitalStyle: 'none',
}
: {}),
})
}}
value={supportStyle}
>
<option value="round">Round</option>
<option value="square">Square</option>
<option value="rectangular">Rectangular</option>
<option value="vertical">Vertical</option>
<option value="a-frame">A-Frame</option>
<option value="y-frame">Y Support</option>
<option value="v-frame">V Support</option>
<option value="x-brace">X Brace</option>
<option value="k-brace">K Brace</option>
<option value="single-strut">Single Strut</option>
<option value="tripod">Tripod Support</option>
<option value="trestle">Trestle Frame</option>
<option value="portal-frame">Portal Frame</option>
<option value="box-frame">Box Frame</option>
</select>
<SliderControl
label="Edge Softness"
max={0.12}
min={0}
onChange={(value) => handleUpdate({ edgeSoftness: value })}
precision={3}
step={0.005}
unit="m"
value={node.edgeSoftness ?? 0.025}
/>
{(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
<SliderControl
label="Shaft Corner Radius"
max={0.3}
min={0}
onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
precision={3}
step={0.005}
unit="m"
value={node.shaftCornerRadius ?? 0.035}
/>
{isBraceSupport ? (
<>
<SliderControl
label="Brace Width"
max={0.8}
min={0.04}
onChange={(value) => handleUpdate({ braceWidth: value, width: value })}
precision={2}
step={0.01}
unit="m"
value={node.braceWidth ?? node.width}
/>
<SliderControl
label="Brace Depth"
max={0.8}
min={0.04}
onChange={(value) => handleUpdate({ braceDepth: value, depth: value })}
precision={2}
step={0.01}
unit="m"
value={node.braceDepth ?? node.depth}
/>
</>
) : (
<>
<select
className={SELECT_CLASS}
onChange={(event) =>
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
}
value={node.crossSection}
>
<option value="round">Round</option>
<option value="square">Square</option>
<option value="rectangular">Rectangular</option>
</select>
<SliderControl
label="Edge Softness"
max={0.12}
min={0}
onChange={(value) => handleUpdate({ edgeSoftness: value })}
precision={3}
step={0.005}
unit="m"
value={node.edgeSoftness ?? 0.025}
/>
{(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
<SliderControl
label="Shaft Corner Radius"
max={0.3}
min={0}
onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
precision={3}
step={0.005}
unit="m"
value={node.shaftCornerRadius ?? 0.035}
/>
)}
</>
)}
</PanelSection>
<PanelSection title="Dimensions">
<select
className={SELECT_CLASS}
onChange={(event) => {
if (!event.target.value) return
handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
}}
value=""
>
<option value="">Apply proportion...</option>
{COLUMN_PROPORTION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{!isBraceSupport && (
<select
className={SELECT_CLASS}
onChange={(event) => {
if (!event.target.value) return
handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
}}
value=""
>
<option value="">Apply proportion...</option>
{COLUMN_PROPORTION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
<SliderControl
label="Height"
max={6}
@@ -280,202 +356,286 @@ export function ColumnPanel() {
unit="m"
value={node.height}
/>
<SliderControl
label="Width"
max={1.6}
min={0.12}
onChange={(value) =>
handleUpdate({
width: value,
radius: value / 2,
...(node.crossSection === 'rectangular' ? {} : { depth: value }),
})
}
precision={2}
step={0.02}
unit="m"
value={node.width}
/>
{node.crossSection === 'rectangular' && (
<SliderControl
label="Depth"
max={1.6}
min={0.12}
onChange={(value) => handleUpdate({ depth: value })}
precision={2}
step={0.02}
unit="m"
value={node.depth}
/>
{isBraceSupport ? (
<>
{(supportStyle === 'a-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'portal-frame' ||
supportStyle === 'box-frame') && (
<SliderControl
label="Bottom Spread"
max={4}
min={0.2}
onChange={(value) =>
handleUpdate({
braceBottomSpread: value,
braceTopSpread:
supportStyle === 'a-frame'
? Math.min(node.braceTopSpread ?? 0.12, value)
: (node.braceTopSpread ?? 1),
})
}
precision={2}
step={0.05}
unit="m"
value={node.braceBottomSpread ?? 1.2}
/>
)}
<SliderControl
label={supportStyle === 'y-frame' ? 'Fork Spread' : 'Top Spread'}
max={
supportStyle === 'y-frame' ||
supportStyle === 'v-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'box-frame'
? 4
: Math.max(0.2, node.braceBottomSpread ?? 1.2)
}
min={0}
onChange={(value) => handleUpdate({ braceTopSpread: value })}
precision={2}
step={0.02}
unit="m"
value={
node.braceTopSpread ??
(supportStyle === 'y-frame' ||
supportStyle === 'v-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'portal-frame' ||
supportStyle === 'box-frame'
? 1
: 0.12)
}
/>
<ToggleControl
checked={node.bracePlateEnabled ?? true}
label="Connector Plates"
onChange={(checked) => handleUpdate({ bracePlateEnabled: checked })}
/>
</>
) : (
<>
<SliderControl
label="Width"
max={1.6}
min={0.12}
onChange={(value) =>
handleUpdate({
width: value,
radius: value / 2,
...(node.crossSection === 'rectangular' ? {} : { depth: value }),
})
}
precision={2}
step={0.02}
unit="m"
value={node.width}
/>
{node.crossSection === 'rectangular' && (
<SliderControl
label="Depth"
max={1.6}
min={0.12}
onChange={(value) => handleUpdate({ depth: value })}
precision={2}
step={0.02}
unit="m"
value={node.depth}
/>
)}
</>
)}
</PanelSection>
<PanelSection title="Shaft">
<select
className={SELECT_CLASS}
onChange={(event) => handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))}
value={shaftProfile}
>
<option value="straight">Straight</option>
<option value="tapered">Tapered</option>
<option value="bulged">Bulged</option>
<option value="hourglass">Hourglass</option>
</select>
{shaftProfile === 'straight' && (
{!isBraceSupport && (
<PanelSection title="Shaft">
<select
className={SELECT_CLASS}
onChange={(event) =>
handleUpdate(
shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']),
)
}
value={shaftProfile}
>
<option value="straight">Straight</option>
<option value="tapered">Tapered</option>
<option value="bulged">Bulged</option>
<option value="hourglass">Hourglass</option>
</select>
{shaftProfile === 'straight' && (
<SliderControl
label="Shaft Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.72}
/>
)}
{shaftProfile === 'tapered' && (
<>
<SliderControl
label="Bottom Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.82}
/>
<SliderControl
label="Top Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftEndScale ?? 0.72}
/>
<SliderControl
label="Taper"
max={0.45}
min={0}
onChange={(value) => handleUpdate({ shaftTaper: value })}
precision={2}
step={0.01}
value={node.shaftTaper ?? 0.14}
/>
</>
)}
{shaftProfile === 'bulged' && (
<>
<SliderControl
label="End Width"
max={1.2}
min={0.3}
onChange={(value) =>
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.68}
/>
<SliderControl
label="Bulge"
max={0.35}
min={0}
onChange={(value) => handleUpdate({ shaftBulge: value })}
precision={2}
step={0.01}
value={node.shaftBulge ?? 0.12}
/>
</>
)}
{shaftProfile === 'hourglass' && (
<>
<SliderControl
label="End Width"
max={1.2}
min={0.3}
onChange={(value) =>
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.84}
/>
<SliderControl
label="Waist"
max={0.35}
min={0}
onChange={(value) => handleUpdate({ shaftBulge: value })}
precision={2}
step={0.01}
value={node.shaftBulge ?? 0.12}
/>
</>
)}
<SliderControl
label="Shaft Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.72}
label="Segment Twist"
max={90}
min={-90}
onChange={(value) =>
handleUpdate({
shaftTwistStep: value,
...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
? { shaftSegmentCount: 12 }
: {}),
})
}
precision={0}
step={5}
unit="°"
value={node.shaftTwistStep ?? 0}
/>
)}
{shaftProfile === 'tapered' && (
<>
{Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
<SliderControl
label="Bottom Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.82}
label="Twist Segments"
max={48}
min={4}
onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
precision={0}
step={1}
value={node.shaftSegmentCount ?? 12}
/>
<SliderControl
label="Top Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftEndScale ?? 0.72}
/>
<SliderControl
label="Taper"
max={0.45}
min={0}
onChange={(value) => handleUpdate({ shaftTaper: value })}
precision={2}
step={0.01}
value={node.shaftTaper ?? 0.14}
/>
</>
)}
{shaftProfile === 'bulged' && (
<>
<SliderControl
label="End Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.68}
/>
<SliderControl
label="Bulge"
max={0.35}
min={0}
onChange={(value) => handleUpdate({ shaftBulge: value })}
precision={2}
step={0.01}
value={node.shaftBulge ?? 0.12}
/>
</>
)}
{shaftProfile === 'hourglass' && (
<>
<SliderControl
label="End Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.84}
/>
<SliderControl
label="Waist"
max={0.35}
min={0}
onChange={(value) => handleUpdate({ shaftBulge: value })}
precision={2}
step={0.01}
value={node.shaftBulge ?? 0.12}
/>
</>
)}
<SliderControl
label="Segment Twist"
max={90}
min={-90}
onChange={(value) =>
handleUpdate({
shaftTwistStep: value,
...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
? { shaftSegmentCount: 12 }
: {}),
})
}
precision={0}
step={5}
unit="°"
value={node.shaftTwistStep ?? 0}
/>
{Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
)}
<SliderControl
label="Twist Segments"
max={48}
min={4}
onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
label="Ring Pairs"
max={4}
min={0}
onChange={(value) =>
handleUpdate({
ringCount: Math.round(value) * 2,
ringPlacement: 'ends',
ringSpread: node.ringSpread ?? 0.16,
ringThickness: node.ringThickness ?? 0.055,
})
}
precision={0}
step={1}
value={node.shaftSegmentCount ?? 12}
value={Math.ceil((node.ringCount ?? 0) / 2)}
/>
)}
<SliderControl
label="Ring Pairs"
max={4}
min={0}
onChange={(value) =>
handleUpdate({
ringCount: Math.round(value) * 2,
ringPlacement: 'ends',
ringSpread: node.ringSpread ?? 0.16,
ringThickness: node.ringThickness ?? 0.055,
})
}
precision={0}
step={1}
value={Math.ceil((node.ringCount ?? 0) / 2)}
/>
{(node.ringCount ?? 0) > 0 && (
<SliderControl
label="Ring Thickness"
max={0.14}
min={0.01}
onChange={(value) => handleUpdate({ ringThickness: value })}
precision={3}
step={0.005}
unit="m"
value={node.ringThickness ?? 0.055}
/>
)}
{(node.ringCount ?? 0) > 0 && (
<SliderControl
label="Ring Spread"
max={0.45}
min={0.04}
onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
precision={2}
step={0.01}
value={node.ringSpread ?? 0.16}
/>
)}
</PanelSection>
{(node.ringCount ?? 0) > 0 && (
<SliderControl
label="Ring Thickness"
max={0.14}
min={0.01}
onChange={(value) => handleUpdate({ ringThickness: value })}
precision={3}
step={0.005}
unit="m"
value={node.ringThickness ?? 0.055}
/>
)}
{(node.ringCount ?? 0) > 0 && (
<SliderControl
label="Ring Spread"
max={0.45}
min={0.04}
onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
precision={2}
step={0.01}
value={node.ringSpread ?? 0.16}
/>
)}
</PanelSection>
)}
{!isBraceSupport && (
<PanelSection title="Ends">
<select
className={SELECT_CLASS}
@@ -685,6 +845,7 @@ export function ColumnPanel() {
/>
)}
</PanelSection>
)}
<PanelSection title="Transform">
<SliderControl
@@ -24,6 +24,7 @@ import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
type FenceStyleValue = 'slat' | 'rail' | 'privacy'
@@ -115,6 +116,12 @@ export function FencePanel() {
options={FENCE_BASE_STYLE_OPTIONS}
value={node.baseStyle}
/>
<ToggleControl
checked={node.showInfill ?? true}
className="mt-2"
label="Fence Infill"
onChange={(checked) => handleUpdate({ showInfill: checked })}
/>
</PanelSection>
<PanelSection title="Dimensions">
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,3 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
type FenceNode,
@@ -7,6 +6,7 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
@@ -69,7 +69,9 @@ function createFenceCurveSpanParts(
const parts: FencePart[] = []
const frameCount = Math.max(
1,
Math.ceil((getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH),
Math.ceil(
(getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH,
),
)
let previous = getFencePointAt(fence, startT)
@@ -164,6 +166,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
const spacing = Math.max(fence.postSpacing * styleDefaults.spacingFactor, postWidth * 1.2)
const edgeInset = Math.max(fence.edgeInset ?? 0.015, 0.005)
const isFloating = fence.baseStyle === 'floating'
const showInfill = fence.showInfill ?? true
const baseY = isFloating ? clearance : 0
const effectiveBaseHeight = baseHeight
const startInsetT = Math.min(0.499, edgeInset / length)
@@ -192,18 +195,18 @@ function createFenceParts(fence: FenceNode): FencePart[] {
)
}
const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
const count = showInfill ? Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1) : 2
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
for (let index = 0; index < count; index += 1) {
const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
const frame = getFencePointAt(fence, t)
const isEdgePost = index === 0 || index === count - 1
const postHeight =
isFloating && isEdgePost
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
: verticalHeight
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
const fullHeightPost = !showInfill || (isFloating && isEdgePost)
const postHeight = fullHeightPost
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
: verticalHeight
const postY = fullHeightPost ? postHeight / 2 : verticalY
parts.push({
position: [frame.point.x, postY, frame.point.y],
@@ -244,7 +247,9 @@ function generateFenceGeometry(fence: FenceNode) {
const geometries = parts.map(createFencePartGeometry)
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose())
geometries.forEach((geometry) => {
geometry.dispose()
})
const mergedUv = merged.getAttribute('uv')
if (mergedUv) {
merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2))