Add column presets and procedural shaft controls
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -4,6 +4,7 @@ import type { Object3D } from 'three'
|
||||
import type {
|
||||
BuildingNode,
|
||||
CeilingNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
FenceNode,
|
||||
GuideNode,
|
||||
@@ -57,6 +58,7 @@ export type ZoneEvent = NodeEvent<ZoneNode>
|
||||
export type SlabEvent = NodeEvent<SlabNode>
|
||||
export type SpawnEvent = NodeEvent<SpawnNode>
|
||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||
export type ColumnEvent = NodeEvent<ColumnNode>
|
||||
export type RoofEvent = NodeEvent<RoofNode>
|
||||
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
||||
export type StairEvent = NodeEvent<StairNode>
|
||||
@@ -169,6 +171,7 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'slab', SlabEvent> &
|
||||
NodeEvents<'spawn', SpawnEvent> &
|
||||
NodeEvents<'ceiling', CeilingEvent> &
|
||||
NodeEvents<'column', ColumnEvent> &
|
||||
NodeEvents<'roof', RoofEvent> &
|
||||
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
||||
NodeEvents<'stair', StairEvent> &
|
||||
|
||||
@@ -13,6 +13,7 @@ export const sceneRegistry = {
|
||||
site: new Set<string>(),
|
||||
building: new Set<string>(),
|
||||
ceiling: new Set<string>(),
|
||||
column: new Set<string>(),
|
||||
level: new Set<string>(),
|
||||
wall: new Set<string>(),
|
||||
fence: new Set<string>(),
|
||||
|
||||
@@ -3,6 +3,7 @@ export type {
|
||||
CameraControlEvent,
|
||||
CameraControlFitSceneEvent,
|
||||
CeilingEvent,
|
||||
ColumnEvent,
|
||||
DoorEvent,
|
||||
EventSuffix,
|
||||
FenceEvent,
|
||||
|
||||
@@ -26,6 +26,20 @@ export {
|
||||
} from './material'
|
||||
export { BuildingNode } from './nodes/building'
|
||||
export { CeilingNode } from './nodes/ceiling'
|
||||
export {
|
||||
COLUMN_PRESETS,
|
||||
ColumnBaseStyle,
|
||||
ColumnCapitalStyle,
|
||||
ColumnCarvingPlacement,
|
||||
ColumnCrossSection,
|
||||
ColumnNode,
|
||||
ColumnPanelShape,
|
||||
type ColumnPresetId,
|
||||
ColumnRingPlacement,
|
||||
ColumnShaftDetail,
|
||||
ColumnShaftProfile,
|
||||
ColumnStyle,
|
||||
} from './nodes/column'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
||||
@@ -50,8 +64,8 @@ export { ScanNode } from './nodes/scan'
|
||||
// Nodes
|
||||
export { SiteNode } from './nodes/site'
|
||||
export { SlabNode } from './nodes/slab'
|
||||
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
|
||||
export { SpawnNode } from './nodes/spawn'
|
||||
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
|
||||
export {
|
||||
getEffectiveStairSurfaceMaterial,
|
||||
StairNode,
|
||||
|
||||
@@ -46,6 +46,7 @@ export const MaterialTarget = z.enum([
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'fence',
|
||||
'column',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'door',
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
|
||||
export const ColumnStyle = z.enum([
|
||||
'plain',
|
||||
'faceted',
|
||||
'fluted',
|
||||
'lathe-turned',
|
||||
'dravidian-carved',
|
||||
'cluster',
|
||||
])
|
||||
|
||||
export const ColumnCrossSection = z.enum([
|
||||
'round',
|
||||
'square',
|
||||
'rectangular',
|
||||
'octagonal',
|
||||
'sixteen-sided',
|
||||
])
|
||||
|
||||
export const ColumnShaftProfile = z.enum(['straight', 'tapered', 'bulged', 'baluster', 'hourglass'])
|
||||
|
||||
export const ColumnShaftDetail = z.enum(['none', 'fluted', 'spiral', 'panelled', 'lathe-turned'])
|
||||
|
||||
export const ColumnPanelShape = z.enum(['rectangle', 'arched', 'diamond'])
|
||||
|
||||
export const ColumnBaseStyle = z.enum([
|
||||
'none',
|
||||
'simple-square',
|
||||
'round-rings',
|
||||
'square-plinth',
|
||||
'stepped-square',
|
||||
'lotus',
|
||||
'ribbed-lotus',
|
||||
'panelled-pedestal',
|
||||
])
|
||||
|
||||
export const ColumnCapitalStyle = z.enum([
|
||||
'none',
|
||||
'simple',
|
||||
'simple-slab',
|
||||
'rounded',
|
||||
'stepped',
|
||||
'doric',
|
||||
'volute',
|
||||
'ionic-volute',
|
||||
'leaf-carved',
|
||||
'corinthian-leaf',
|
||||
'south-indian-bracket',
|
||||
'wood-bracket',
|
||||
])
|
||||
|
||||
export const ColumnRingPlacement = z.enum(['ends', 'even', 'top', 'bottom'])
|
||||
|
||||
export const ColumnCarvingPlacement = z.enum(['shaft', 'base', 'capital', 'all'])
|
||||
|
||||
export type ColumnStyle = z.infer<typeof ColumnStyle>
|
||||
export type ColumnCrossSection = z.infer<typeof ColumnCrossSection>
|
||||
export type ColumnShaftProfile = z.infer<typeof ColumnShaftProfile>
|
||||
export type ColumnShaftDetail = z.infer<typeof ColumnShaftDetail>
|
||||
export type ColumnPanelShape = z.infer<typeof ColumnPanelShape>
|
||||
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 const ColumnNode = BaseNode.extend({
|
||||
id: objectId('column'),
|
||||
type: nodeType('column'),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.number().default(0),
|
||||
style: ColumnStyle.default('plain'),
|
||||
crossSection: ColumnCrossSection.default('round'),
|
||||
height: z.number().positive().default(2.8),
|
||||
radius: z.number().positive().default(0.22),
|
||||
width: z.number().positive().default(0.44),
|
||||
depth: z.number().positive().default(0.44),
|
||||
edgeSoftness: z.number().min(0).max(0.12).default(0.025),
|
||||
baseHeight: z.number().min(0).default(0.18),
|
||||
capitalHeight: z.number().min(0).default(0.18),
|
||||
shaftProfile: ColumnShaftProfile.default('straight'),
|
||||
shaftTaper: z.number().min(0).max(0.85).default(0),
|
||||
shaftBulge: z.number().min(-0.5).max(0.8).default(0),
|
||||
shaftStartScale: z.number().min(0.2).max(2).default(0.72),
|
||||
shaftEndScale: z.number().min(0.2).max(2).default(0.72),
|
||||
shaftSegmentCount: z.number().int().min(1).max(64).default(24),
|
||||
shaftCornerRadius: z.number().min(0).max(0.3).default(0.035),
|
||||
shaftDetail: ColumnShaftDetail.default('none'),
|
||||
baseStyle: ColumnBaseStyle.default('round-rings'),
|
||||
baseWidthScale: z.number().min(0.4).max(3).default(1.24),
|
||||
baseDepthScale: z.number().min(0.4).max(3).default(1.24),
|
||||
baseTierCount: z.number().int().min(1).max(8).default(3),
|
||||
baseStepSpread: z.number().min(0).max(1).default(0.42),
|
||||
basePlinthHeightRatio: z.number().min(0.2).max(0.7).default(0.44),
|
||||
baseRoundBandScale: z.number().min(0.5).max(1.2).default(0.92),
|
||||
baseNeckScale: z.number().min(0.35).max(1).default(0.72),
|
||||
baseRoundBandCount: z.number().int().min(0).max(16).default(3),
|
||||
baseRibCount: z.number().int().min(0).max(48).default(0),
|
||||
baseCarvingLevel: z.number().int().min(0).max(4).default(0),
|
||||
basePanelInset: z.number().min(0).max(0.1).default(0.02),
|
||||
capitalStyle: ColumnCapitalStyle.default('simple'),
|
||||
capitalWidthScale: z.number().min(0.4).max(3).default(1.46),
|
||||
capitalDepthScale: z.number().min(0.4).max(3).default(1.46),
|
||||
capitalTierCount: z.number().int().min(1).max(8).default(3),
|
||||
capitalStepSpread: z.number().min(0).max(1).default(0.42),
|
||||
capitalBandCount: z.number().int().min(0).max(16).default(2),
|
||||
voluteSize: z.number().min(0.02).max(0.3).default(0.08),
|
||||
voluteCount: z.number().int().min(0).max(8).default(4),
|
||||
leafCount: z.number().int().min(0).max(48).default(18),
|
||||
leafRows: z.number().int().min(0).max(4).default(2),
|
||||
bracketDepth: z.number().min(0).max(1.5).default(0.35),
|
||||
bracketTierCount: z.number().int().min(0).max(8).default(3),
|
||||
pendantCount: z.number().int().min(0).max(16).default(0),
|
||||
capitalCarvingLevel: z.number().int().min(0).max(4).default(0),
|
||||
ringCount: z.number().int().min(0).max(16).default(0),
|
||||
ringPlacement: ColumnRingPlacement.default('ends'),
|
||||
ringThickness: z.number().min(0.01).max(0.14).default(0.055),
|
||||
ringSpread: z.number().min(0.04).max(0.45).default(0.16),
|
||||
fluteCount: z.number().int().min(0).max(32).default(0),
|
||||
fluteDepth: z.number().min(0.005).max(0.08).default(0.02),
|
||||
fluteWidth: z.number().min(0.005).max(0.1).default(0.02),
|
||||
spiralTwist: z.number().min(-3).max(3).default(0),
|
||||
spiralRibCount: z.number().int().min(0).max(32).default(0),
|
||||
panelCount: z.number().int().min(0).max(24).default(0),
|
||||
panelInsetDepth: z.number().min(0).max(0.1).default(0.02),
|
||||
panelShape: ColumnPanelShape.default('rectangle'),
|
||||
latheRingCount: z.number().int().min(0).max(32).default(0),
|
||||
latheRingSpacing: ColumnRingPlacement.default('ends'),
|
||||
carvingLevel: z.number().int().min(0).max(4).default(0),
|
||||
carvingPlacement: ColumnCarvingPlacement.default('capital'),
|
||||
lowerBandEnabled: z.boolean().default(false),
|
||||
lowerBandHeight: z.number().min(0).max(1).default(0.24),
|
||||
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),
|
||||
material: MaterialSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
}).describe(dedent`
|
||||
Column node - used to represent structural or decorative pillars/columns.
|
||||
- style: visual approach such as plain, lathe-turned, carved, or cluster
|
||||
- crossSection: plan shape used by the procedural renderer
|
||||
- height/radius/width/depth: primary dimensions in meters
|
||||
- edgeSoftness: bevel radius for square/plinth/block edges
|
||||
- shaftProfile/shaftDetail: profile and surface treatment of the shaft
|
||||
- shaftCornerRadius: rounded-corner radius for square/rectangular shaft cross-sections
|
||||
- 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
|
||||
`)
|
||||
|
||||
export const COLUMN_PRESETS = {
|
||||
basicPillar: {
|
||||
label: 'Straight Round',
|
||||
style: 'plain',
|
||||
crossSection: 'round',
|
||||
height: 2.9,
|
||||
radius: 0.22,
|
||||
width: 0.44,
|
||||
depth: 0.44,
|
||||
edgeSoftness: 0.025,
|
||||
baseHeight: 0.22,
|
||||
capitalHeight: 0.2,
|
||||
shaftProfile: 'straight',
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0,
|
||||
shaftStartScale: 0.72,
|
||||
shaftEndScale: 0.72,
|
||||
shaftSegmentCount: 1,
|
||||
shaftCornerRadius: 0.035,
|
||||
shaftDetail: 'none',
|
||||
baseStyle: 'square-plinth',
|
||||
baseWidthScale: 1.26,
|
||||
baseDepthScale: 1.26,
|
||||
baseTierCount: 1,
|
||||
baseStepSpread: 0.34,
|
||||
basePlinthHeightRatio: 0.44,
|
||||
baseRoundBandScale: 0.92,
|
||||
baseNeckScale: 0.72,
|
||||
baseRoundBandCount: 0,
|
||||
baseRibCount: 0,
|
||||
baseCarvingLevel: 0,
|
||||
capitalStyle: 'simple-slab',
|
||||
capitalWidthScale: 1.22,
|
||||
capitalDepthScale: 1.22,
|
||||
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,
|
||||
},
|
||||
squarePillar: {
|
||||
label: 'Square Block',
|
||||
style: 'faceted',
|
||||
crossSection: 'square',
|
||||
height: 2.9,
|
||||
radius: 0.24,
|
||||
width: 0.48,
|
||||
depth: 0.48,
|
||||
edgeSoftness: 0.035,
|
||||
baseHeight: 0.22,
|
||||
capitalHeight: 0.2,
|
||||
shaftProfile: 'straight',
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0,
|
||||
shaftStartScale: 0.72,
|
||||
shaftEndScale: 0.72,
|
||||
shaftSegmentCount: 1,
|
||||
shaftCornerRadius: 0.045,
|
||||
shaftDetail: 'none',
|
||||
baseStyle: 'simple-square',
|
||||
baseWidthScale: 1.18,
|
||||
baseDepthScale: 1.18,
|
||||
baseTierCount: 1,
|
||||
baseStepSpread: 0.34,
|
||||
basePlinthHeightRatio: 0.44,
|
||||
baseRoundBandScale: 0.92,
|
||||
baseNeckScale: 0.72,
|
||||
baseRoundBandCount: 0,
|
||||
baseRibCount: 0,
|
||||
baseCarvingLevel: 0,
|
||||
capitalStyle: 'simple-slab',
|
||||
capitalWidthScale: 1.18,
|
||||
capitalDepthScale: 1.18,
|
||||
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,
|
||||
},
|
||||
taperedPillar: {
|
||||
label: 'Tapered Round',
|
||||
style: 'plain',
|
||||
crossSection: 'round',
|
||||
height: 3,
|
||||
radius: 0.23,
|
||||
width: 0.46,
|
||||
depth: 0.46,
|
||||
edgeSoftness: 0.025,
|
||||
baseHeight: 0.23,
|
||||
capitalHeight: 0.2,
|
||||
shaftProfile: 'tapered',
|
||||
shaftTaper: 0.14,
|
||||
shaftBulge: 0,
|
||||
shaftStartScale: 0.82,
|
||||
shaftEndScale: 0.72,
|
||||
shaftSegmentCount: 32,
|
||||
shaftCornerRadius: 0.035,
|
||||
shaftDetail: 'none',
|
||||
baseStyle: 'square-plinth',
|
||||
baseWidthScale: 1.26,
|
||||
baseDepthScale: 1.26,
|
||||
baseTierCount: 1,
|
||||
baseStepSpread: 0.34,
|
||||
basePlinthHeightRatio: 0.44,
|
||||
baseRoundBandScale: 0.92,
|
||||
baseNeckScale: 0.72,
|
||||
baseRoundBandCount: 0,
|
||||
baseRibCount: 0,
|
||||
baseCarvingLevel: 0,
|
||||
capitalStyle: 'simple-slab',
|
||||
capitalWidthScale: 1.22,
|
||||
capitalDepthScale: 1.22,
|
||||
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,
|
||||
},
|
||||
bulgedPillar: {
|
||||
label: 'Soft Bulged',
|
||||
style: 'plain',
|
||||
crossSection: 'round',
|
||||
height: 2.9,
|
||||
radius: 0.22,
|
||||
width: 0.44,
|
||||
depth: 0.44,
|
||||
edgeSoftness: 0.025,
|
||||
baseHeight: 0.22,
|
||||
capitalHeight: 0.2,
|
||||
shaftProfile: 'bulged',
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0.12,
|
||||
shaftStartScale: 0.68,
|
||||
shaftEndScale: 0.68,
|
||||
shaftSegmentCount: 32,
|
||||
shaftCornerRadius: 0.035,
|
||||
shaftDetail: 'none',
|
||||
baseStyle: 'square-plinth',
|
||||
baseWidthScale: 1.24,
|
||||
baseDepthScale: 1.24,
|
||||
baseTierCount: 1,
|
||||
baseStepSpread: 0.34,
|
||||
basePlinthHeightRatio: 0.44,
|
||||
baseRoundBandScale: 0.92,
|
||||
baseNeckScale: 0.72,
|
||||
baseRoundBandCount: 0,
|
||||
baseRibCount: 0,
|
||||
baseCarvingLevel: 0,
|
||||
capitalStyle: 'simple-slab',
|
||||
capitalWidthScale: 1.2,
|
||||
capitalDepthScale: 1.2,
|
||||
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,
|
||||
},
|
||||
hourglassPillar: {
|
||||
label: 'Hourglass',
|
||||
style: 'plain',
|
||||
crossSection: 'round',
|
||||
height: 2.9,
|
||||
radius: 0.22,
|
||||
width: 0.44,
|
||||
depth: 0.44,
|
||||
edgeSoftness: 0.025,
|
||||
baseHeight: 0.22,
|
||||
capitalHeight: 0.2,
|
||||
shaftProfile: 'hourglass',
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0.12,
|
||||
shaftStartScale: 0.84,
|
||||
shaftEndScale: 0.84,
|
||||
shaftSegmentCount: 32,
|
||||
shaftCornerRadius: 0.035,
|
||||
shaftDetail: 'none',
|
||||
baseStyle: 'square-plinth',
|
||||
baseWidthScale: 1.24,
|
||||
baseDepthScale: 1.24,
|
||||
baseTierCount: 1,
|
||||
baseStepSpread: 0.34,
|
||||
basePlinthHeightRatio: 0.44,
|
||||
baseRoundBandScale: 0.92,
|
||||
baseNeckScale: 0.72,
|
||||
baseRoundBandCount: 0,
|
||||
baseRibCount: 0,
|
||||
baseCarvingLevel: 0,
|
||||
capitalStyle: 'simple-slab',
|
||||
capitalWidthScale: 1.2,
|
||||
capitalDepthScale: 1.2,
|
||||
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,
|
||||
},
|
||||
} as const satisfies Record<string, { label: string } & Partial<z.input<typeof ColumnNode>>>
|
||||
|
||||
export type ColumnPresetId = keyof typeof COLUMN_PRESETS
|
||||
|
||||
export type ColumnNode = z.infer<typeof ColumnNode>
|
||||
@@ -2,6 +2,7 @@ import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { CeilingNode } from './ceiling'
|
||||
import { ColumnNode } from './column'
|
||||
import { FenceNode } from './fence'
|
||||
import { GuideNode } from './guide'
|
||||
import { ItemNode } from './item'
|
||||
@@ -21,6 +22,7 @@ export const LevelNode = BaseNode.extend({
|
||||
z.union([
|
||||
WallNode.shape.id,
|
||||
FenceNode.shape.id,
|
||||
ColumnNode.shape.id,
|
||||
ItemNode.shape.id,
|
||||
ZoneNode.shape.id,
|
||||
SlabNode.shape.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from 'zod'
|
||||
import { BuildingNode } from './nodes/building'
|
||||
import { CeilingNode } from './nodes/ceiling'
|
||||
import { ColumnNode } from './nodes/column'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { FenceNode } from './nodes/fence'
|
||||
import { GuideNode } from './nodes/guide'
|
||||
@@ -22,6 +23,7 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
SiteNode,
|
||||
BuildingNode,
|
||||
LevelNode,
|
||||
ColumnNode,
|
||||
WallNode,
|
||||
FenceNode,
|
||||
ItemNode,
|
||||
|
||||
@@ -25,8 +25,8 @@ import { Move } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { NodeActionMenu } from './node-action-menu'
|
||||
|
||||
@@ -40,6 +40,7 @@ const ALLOWED_TYPES = [
|
||||
'stair-segment',
|
||||
'wall',
|
||||
'fence',
|
||||
'column',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'spawn',
|
||||
@@ -147,10 +148,7 @@ export function FloatingActionMenu() {
|
||||
node.type === 'wall'
|
||||
? obj.localToWorld(
|
||||
new THREE.Vector3(
|
||||
Math.hypot(
|
||||
segment.end[0] - segment.start[0],
|
||||
segment.end[1] - segment.start[1],
|
||||
),
|
||||
Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
@@ -186,6 +184,7 @@ export function FloatingActionMenu() {
|
||||
node.type === 'door' ||
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'spawn' ||
|
||||
@@ -335,7 +334,7 @@ export function FloatingActionMenu() {
|
||||
} else if (duplicate.type === 'stair') {
|
||||
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
||||
}
|
||||
if (duplicate.type !== 'stair' && duplicate.type !== 'roof') {
|
||||
if (duplicate.type !== 'stair') {
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}
|
||||
@@ -426,6 +425,7 @@ export function FloatingActionMenu() {
|
||||
onDuplicate={
|
||||
node &&
|
||||
node.type !== 'spawn' &&
|
||||
node.type !== 'column' &&
|
||||
!DELETE_ONLY_TYPES.includes(node.type) &&
|
||||
!HOLE_TYPES.includes(node.type)
|
||||
? handleDuplicate
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
getMaterialPresetByRef,
|
||||
@@ -65,6 +66,7 @@ type SelectableNodeType =
|
||||
| 'wall'
|
||||
| 'fence'
|
||||
| 'item'
|
||||
| 'column'
|
||||
| 'building'
|
||||
| 'zone'
|
||||
| 'slab'
|
||||
@@ -333,7 +335,7 @@ function applyStairPaintPreview(
|
||||
}
|
||||
|
||||
function applySingleSurfacePaintPreview(
|
||||
node: FenceNode | SlabNode | CeilingNode,
|
||||
node: FenceNode | ColumnNode | SlabNode | CeilingNode,
|
||||
material: ActivePaintMaterial,
|
||||
): PaintPreviewCleanup | null {
|
||||
if (node.type === 'ceiling') {
|
||||
@@ -377,12 +379,32 @@ function applySingleSurfacePaintPreview(
|
||||
}
|
||||
}
|
||||
|
||||
const mesh = getRegisteredMesh(node.id)
|
||||
if (!mesh) return null
|
||||
const registeredObject = getRegisteredNodeObject(node.id)
|
||||
const mesh =
|
||||
registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null
|
||||
|
||||
const previewMaterial = getSingleSurfacePreviewMaterial(material)
|
||||
if (!previewMaterial) return null
|
||||
|
||||
if (node.type === 'column') {
|
||||
if (!registeredObject) return null
|
||||
const restores: PaintPreviewCleanup[] = []
|
||||
|
||||
registeredObject.traverse((object) => {
|
||||
if (!(object as Mesh).isMesh) return
|
||||
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
|
||||
})
|
||||
|
||||
if (restores.length === 0) return null
|
||||
return () => {
|
||||
for (let index = restores.length - 1; index >= 0; index -= 1) {
|
||||
restores[index]?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mesh) return null
|
||||
|
||||
if (node.type === 'slab') {
|
||||
const slabMaterial = previewMaterial.clone()
|
||||
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
|
||||
@@ -542,6 +564,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'zone',
|
||||
'slab',
|
||||
'ceiling',
|
||||
@@ -595,6 +618,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
@@ -658,6 +682,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
||||
if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
@@ -857,7 +882,12 @@ export const SelectionManager = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') {
|
||||
if (
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling'
|
||||
) {
|
||||
const compatible = hasActivePaintMaterial(activePaintMaterial)
|
||||
|
||||
return {
|
||||
@@ -870,17 +900,16 @@ export const SelectionManager = () => {
|
||||
.getState()
|
||||
.updateNode(
|
||||
node.id as AnyNodeId,
|
||||
buildSingleSurfaceMaterialPatch<FenceNode | SlabNode | CeilingNode>(
|
||||
activePaintMaterial.material,
|
||||
activePaintMaterial.materialPreset,
|
||||
),
|
||||
buildSingleSurfaceMaterialPatch<
|
||||
FenceNode | ColumnNode | SlabNode | CeilingNode
|
||||
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
|
||||
)
|
||||
}
|
||||
: null,
|
||||
preview: compatible
|
||||
? () =>
|
||||
applySingleSurfacePaintPreview(
|
||||
node as FenceNode | SlabNode | CeilingNode,
|
||||
node as FenceNode | ColumnNode | SlabNode | CeilingNode,
|
||||
activePaintMaterial,
|
||||
)
|
||||
: () => previewCursor('not-allowed'),
|
||||
@@ -963,6 +992,7 @@ export const SelectionManager = () => {
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
@@ -1131,6 +1161,7 @@ export const SelectionManager = () => {
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'building',
|
||||
'zone',
|
||||
'slab',
|
||||
@@ -1227,6 +1258,7 @@ export const SelectionManager = () => {
|
||||
} else if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
@@ -1279,6 +1311,7 @@ export const SelectionManager = () => {
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'building',
|
||||
'slab',
|
||||
'ceiling',
|
||||
@@ -1352,6 +1385,7 @@ export const SelectionManager = () => {
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import '../../../three-types'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
COLUMN_PRESETS,
|
||||
ColumnNode,
|
||||
type ColumnPresetId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const COLUMN_ICON = (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt="Column"
|
||||
src="/icons/column.png"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
)
|
||||
|
||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
|
||||
|
||||
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
|
||||
const { label, ...preset } = COLUMN_PRESETS[presetId]
|
||||
return ColumnNode.parse({
|
||||
name: label,
|
||||
position,
|
||||
rotation: 0,
|
||||
...preset,
|
||||
})
|
||||
}
|
||||
|
||||
type ColumnToolProps = {
|
||||
currentLevelId: LevelNode['id'] | null
|
||||
}
|
||||
|
||||
export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId }) => {
|
||||
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
|
||||
const cursorRef = useRef<Group>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const nextPosition: [number, number, number] = [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
0,
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
setCursorPosition(nextPosition)
|
||||
cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2])
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const position: [number, number, number] = [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
0,
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
|
||||
useScene.getState().createNode(column, currentLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [column.id as AnyNode['id']] })
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
useEditor.getState().setTool(null)
|
||||
useEditor.getState().setMode('select')
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
}
|
||||
}, [currentLevelId])
|
||||
|
||||
if (!currentLevelId) return null
|
||||
|
||||
return (
|
||||
<CursorSphere
|
||||
color="#a78bfa"
|
||||
height={2.8}
|
||||
ref={cursorRef}
|
||||
showTooltip
|
||||
tooltipContent={COLUMN_ICON}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import '../../../three-types'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
ColumnNode,
|
||||
type ColumnNode as ColumnNodeType,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||
|
||||
export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
|
||||
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
let committed = false
|
||||
|
||||
const applyPreview = (position: [number, number, number]) => {
|
||||
setPreviewPosition(position)
|
||||
useLiveTransforms.getState().set(node.id, {
|
||||
position,
|
||||
rotation: node.rotation,
|
||||
})
|
||||
sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const position: [number, number, number] = [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
0,
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
const nodeId = (node as { id?: ColumnNodeType['id'] }).id
|
||||
|
||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||
committed = true
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(nodeId, { position })
|
||||
} else if (node.parentId) {
|
||||
const column = ColumnNode.parse({
|
||||
...node,
|
||||
id: undefined,
|
||||
metadata: {},
|
||||
position,
|
||||
})
|
||||
committed = true
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().createNode(column, node.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
sceneRegistry.nodes
|
||||
.get(node.id)
|
||||
?.position.set(node.position[0], node.position[1], node.position[2])
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
if (!committed) {
|
||||
sceneRegistry.nodes
|
||||
.get(node.id)
|
||||
?.position.set(node.position[0], node.position[1], node.position[2])
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
BuildingNode,
|
||||
CeilingNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
FenceNode,
|
||||
ItemNode,
|
||||
@@ -18,6 +19,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { MoveBuildingContent } from '../building/move-building-tool'
|
||||
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
||||
import { MoveColumnTool } from '../column/move-column-tool'
|
||||
import { MoveDoorTool } from '../door/move-door-tool'
|
||||
import { MoveFenceTool } from '../fence/move-fence-tool'
|
||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||
@@ -100,6 +102,7 @@ export const MoveTool: React.FC<{
|
||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
||||
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
||||
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
||||
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
||||
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
||||
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||
|
||||
@@ -10,6 +10,7 @@ import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
||||
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
||||
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||
import { ColumnTool } from './column/column-tool'
|
||||
import { DoorTool } from './door/door-tool'
|
||||
import { CurveFenceTool } from './fence/curve-fence-tool'
|
||||
import { FenceTool } from './fence/fence-tool'
|
||||
@@ -164,7 +165,10 @@ export const ToolManager: React.FC = () => {
|
||||
{!movingNode && showBuildTool && tool === 'spawn' && (
|
||||
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handleSpawnSelected} />
|
||||
)}
|
||||
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
||||
{!movingNode && showBuildTool && tool === 'column' && (
|
||||
<ColumnTool currentLevelId={selectedLevelId} />
|
||||
)}
|
||||
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
|
||||
</group>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ export const tools: ToolConfig[] = [
|
||||
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
|
||||
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
||||
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
|
||||
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
||||
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
||||
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
COLUMN_PRESETS,
|
||||
type ColumnNode,
|
||||
type ColumnPresetId,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
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 { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
const SELECT_CLASS =
|
||||
'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border'
|
||||
|
||||
const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({
|
||||
value: value as ColumnPresetId,
|
||||
label: preset.label,
|
||||
}))
|
||||
|
||||
const COLUMN_PROPORTION_PRESETS = {
|
||||
slender: {
|
||||
label: 'Slender',
|
||||
height: 3.6,
|
||||
width: 0.34,
|
||||
baseHeight: 0.18,
|
||||
capitalHeight: 0.16,
|
||||
baseWidthScale: 1.18,
|
||||
capitalWidthScale: 1.16,
|
||||
edgeSoftness: 0.02,
|
||||
},
|
||||
standard: {
|
||||
label: 'Standard',
|
||||
height: 2.9,
|
||||
width: 0.44,
|
||||
baseHeight: 0.22,
|
||||
capitalHeight: 0.2,
|
||||
baseWidthScale: 1.24,
|
||||
capitalWidthScale: 1.22,
|
||||
edgeSoftness: 0.025,
|
||||
},
|
||||
heavy: {
|
||||
label: 'Heavy',
|
||||
height: 3,
|
||||
width: 0.58,
|
||||
baseHeight: 0.28,
|
||||
capitalHeight: 0.26,
|
||||
baseWidthScale: 1.34,
|
||||
capitalWidthScale: 1.3,
|
||||
edgeSoftness: 0.035,
|
||||
},
|
||||
stout: {
|
||||
label: 'Short / Stout',
|
||||
height: 2.2,
|
||||
width: 0.62,
|
||||
baseHeight: 0.3,
|
||||
capitalHeight: 0.28,
|
||||
baseWidthScale: 1.38,
|
||||
capitalWidthScale: 1.34,
|
||||
edgeSoftness: 0.04,
|
||||
},
|
||||
} as const
|
||||
|
||||
type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
|
||||
|
||||
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({
|
||||
value: value as ColumnProportionPresetId,
|
||||
label: preset.label,
|
||||
}))
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function presetUpdates(presetId: ColumnPresetId): Partial<ColumnNode> {
|
||||
const { label, ...preset } = COLUMN_PRESETS[presetId]
|
||||
return {
|
||||
name: label,
|
||||
...preset,
|
||||
}
|
||||
}
|
||||
|
||||
function proportionUpdates(
|
||||
node: ColumnNode,
|
||||
presetId: ColumnProportionPresetId,
|
||||
): Partial<ColumnNode> {
|
||||
const preset = COLUMN_PROPORTION_PRESETS[presetId]
|
||||
const depth =
|
||||
node.crossSection === 'rectangular'
|
||||
? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6)
|
||||
: preset.width
|
||||
const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18)
|
||||
|
||||
return {
|
||||
height: preset.height,
|
||||
width: preset.width,
|
||||
depth,
|
||||
radius: preset.width / 2,
|
||||
baseHeight: preset.baseHeight,
|
||||
capitalHeight: preset.capitalHeight,
|
||||
baseWidthScale: preset.baseWidthScale,
|
||||
baseDepthScale: preset.baseWidthScale,
|
||||
capitalWidthScale: preset.capitalWidthScale,
|
||||
capitalDepthScale: preset.capitalWidthScale,
|
||||
edgeSoftness: preset.edgeSoftness,
|
||||
shaftCornerRadius,
|
||||
}
|
||||
}
|
||||
|
||||
function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial<ColumnNode> {
|
||||
if (shaftProfile === 'tapered') {
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0.14,
|
||||
shaftBulge: 0,
|
||||
shaftStartScale: 0.82,
|
||||
shaftEndScale: 0.72,
|
||||
shaftSegmentCount: 32,
|
||||
}
|
||||
}
|
||||
|
||||
if (shaftProfile === 'bulged') {
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0.12,
|
||||
shaftStartScale: 0.68,
|
||||
shaftEndScale: 0.68,
|
||||
shaftSegmentCount: 32,
|
||||
}
|
||||
}
|
||||
|
||||
if (shaftProfile === 'hourglass') {
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0.12,
|
||||
shaftStartScale: 0.84,
|
||||
shaftEndScale: 0.84,
|
||||
shaftSegmentCount: 32,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0,
|
||||
shaftStartScale: 0.72,
|
||||
shaftEndScale: 0.72,
|
||||
shaftSegmentCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function ColumnPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ColumnNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<ColumnNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [deleteNode, selectedId, setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
|
||||
const shaftProfile = node.shaftProfile ?? 'straight'
|
||||
|
||||
return (
|
||||
<PanelWrapper icon="/icons/column.png" onClose={handleClose} title={node.name || 'Column'} width={300}>
|
||||
<PanelSection title="Preset">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) return
|
||||
handleUpdate(presetUpdates(event.target.value as ColumnPresetId))
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Apply preset...</option>
|
||||
{COLUMN_PRESET_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Shape">
|
||||
<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>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={6}
|
||||
min={0.8}
|
||||
onChange={(value) => handleUpdate({ height: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</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' && (
|
||||
<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="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>
|
||||
|
||||
<PanelSection title="Ends">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
const capitalStyle = event.target.value as ColumnNode['capitalStyle']
|
||||
handleUpdate({
|
||||
capitalStyle,
|
||||
...(capitalStyle === 'none'
|
||||
? {}
|
||||
: {
|
||||
capitalHeight: Math.max(node.capitalHeight, 0.12),
|
||||
capitalTierCount: capitalStyle === 'stepped' ? Math.max(node.capitalTierCount ?? 3, 3) : node.capitalTierCount,
|
||||
capitalWidthScale: Math.max(node.capitalWidthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
|
||||
capitalDepthScale: Math.max(node.capitalDepthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
|
||||
capitalStepSpread: capitalStyle === 'stepped' ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) : node.capitalStepSpread,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')}
|
||||
>
|
||||
<option value="none">No Top</option>
|
||||
<option value="simple">Simple Top</option>
|
||||
<option value="stepped">Stepped Top</option>
|
||||
<option value="rounded">Rounded Top</option>
|
||||
</select>
|
||||
{node.capitalStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Top Height"
|
||||
max={0.8}
|
||||
min={0.06}
|
||||
onChange={(value) => handleUpdate({ capitalHeight: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.capitalHeight}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Top Width"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
capitalWidthScale: value,
|
||||
...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.capitalWidthScale ?? 1.28}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Top Depth"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) => handleUpdate({ capitalDepthScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle === 'stepped' && (
|
||||
<SliderControl
|
||||
label="Top Tiers"
|
||||
max={8}
|
||||
min={3}
|
||||
onChange={(value) => handleUpdate({ capitalTierCount: Math.round(value) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.capitalTierCount ?? 3}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle === 'stepped' && (
|
||||
<SliderControl
|
||||
label="Top Step Spread"
|
||||
max={0.9}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ capitalStepSpread: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.capitalStepSpread ?? 0.34}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
className={`${SELECT_CLASS} mt-2`}
|
||||
onChange={(event) => {
|
||||
const baseStyle = event.target.value as ColumnNode['baseStyle']
|
||||
handleUpdate({
|
||||
baseStyle,
|
||||
...(baseStyle === 'none'
|
||||
? {}
|
||||
: {
|
||||
baseHeight: Math.max(node.baseHeight, 0.12),
|
||||
baseTierCount: baseStyle === 'stepped-square' ? Math.max(node.baseTierCount ?? 3, 3) : node.baseTierCount,
|
||||
baseWidthScale: Math.max(node.baseWidthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
|
||||
baseDepthScale: Math.max(node.baseDepthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
|
||||
baseStepSpread: baseStyle === 'stepped-square' ? Math.max(node.baseStepSpread ?? 0.34, 0.34) : node.baseStepSpread,
|
||||
basePlinthHeightRatio: baseStyle === 'round-rings' ? (node.basePlinthHeightRatio ?? 0.44) : node.basePlinthHeightRatio,
|
||||
baseRoundBandScale: baseStyle === 'round-rings' ? (node.baseRoundBandScale ?? 0.92) : node.baseRoundBandScale,
|
||||
baseNeckScale: baseStyle === 'round-rings' ? (node.baseNeckScale ?? 0.72) : node.baseNeckScale,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
value={node.baseStyle ?? 'square-plinth'}
|
||||
>
|
||||
<option value="none">No Bottom</option>
|
||||
<option value="simple-square">Simple Block Bottom</option>
|
||||
<option value="square-plinth">Square Plinth Bottom</option>
|
||||
<option value="stepped-square">Stepped Bottom</option>
|
||||
<option value="round-rings">Rounded Bottom</option>
|
||||
</select>
|
||||
{node.baseStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Bottom Height"
|
||||
max={0.8}
|
||||
min={0.06}
|
||||
onChange={(value) => handleUpdate({ baseHeight: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.baseHeight}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Bottom Width"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
baseWidthScale: value,
|
||||
...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.baseWidthScale ?? 1.24}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle !== 'none' && node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Bottom Depth"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) => handleUpdate({ baseDepthScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'round-rings' && (
|
||||
<SliderControl
|
||||
label="Plinth Thickness"
|
||||
max={0.7}
|
||||
min={0.2}
|
||||
onChange={(value) => handleUpdate({ basePlinthHeightRatio: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.basePlinthHeightRatio ?? 0.44}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'round-rings' && (
|
||||
<SliderControl
|
||||
label="Round Band Width"
|
||||
max={1.2}
|
||||
min={0.5}
|
||||
onChange={(value) => handleUpdate({ baseRoundBandScale: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.baseRoundBandScale ?? 0.92}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'round-rings' && (
|
||||
<SliderControl
|
||||
label="Neck Width"
|
||||
max={1}
|
||||
min={0.35}
|
||||
onChange={(value) => handleUpdate({ baseNeckScale: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.baseNeckScale ?? 0.72}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'stepped-square' && (
|
||||
<SliderControl
|
||||
label="Bottom Tiers"
|
||||
max={8}
|
||||
min={3}
|
||||
onChange={(value) => handleUpdate({ baseTierCount: Math.round(value) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.baseTierCount ?? 3}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'stepped-square' && (
|
||||
<SliderControl
|
||||
label="Bottom Step Spread"
|
||||
max={0.9}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ baseStepSpread: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.baseStepSpread ?? 0.34}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Transform">
|
||||
<SliderControl
|
||||
label="Yaw"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(value) => handleUpdate({ rotation: (value * Math.PI) / 180 })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
|
||||
window: { icon: '/icons/window.png', label: 'Window' },
|
||||
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
||||
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
||||
column: { icon: '/icons/column.png', label: 'Column' },
|
||||
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
||||
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
||||
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
type DoorNode,
|
||||
type FenceNode,
|
||||
type ItemNode,
|
||||
@@ -23,6 +24,7 @@ import { useIsMobile } from '../../../hooks/use-mobile'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CeilingPanel } from './ceiling-panel'
|
||||
import { ColumnPanel } from './column-panel'
|
||||
import { DoorPanel } from './door-panel'
|
||||
import { FencePanel } from './fence-panel'
|
||||
import { ItemPanel } from './item-panel'
|
||||
@@ -45,6 +47,7 @@ type MovableNode =
|
||||
| WindowNode
|
||||
| DoorNode
|
||||
| CeilingNode
|
||||
| ColumnNode
|
||||
| SlabNode
|
||||
| WallNode
|
||||
| FenceNode
|
||||
@@ -59,6 +62,7 @@ const MOVABLE_TYPES = new Set<string>([
|
||||
'window',
|
||||
'door',
|
||||
'ceiling',
|
||||
'column',
|
||||
'slab',
|
||||
'wall',
|
||||
'fence',
|
||||
@@ -90,6 +94,8 @@ function panelForType(type: string | null) {
|
||||
return <SlabPanel />
|
||||
case 'ceiling':
|
||||
return <CeilingPanel />
|
||||
case 'column':
|
||||
return <ColumnPanel />
|
||||
case 'wall':
|
||||
return <WallPanel />
|
||||
case 'fence':
|
||||
@@ -250,6 +256,8 @@ export function PanelManager() {
|
||||
return <SpawnPanel />
|
||||
case 'ceiling':
|
||||
return <CeilingPanel />
|
||||
case 'column':
|
||||
return <ColumnPanel />
|
||||
case 'wall':
|
||||
return <WallPanel />
|
||||
case 'fence':
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type AnyNodeId, type ColumnNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface ColumnTreeNodeProps {
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export const ColumnTreeNode = memo(function ColumnTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: ColumnTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const node = useScene((s) => s.nodes[nodeId] as ColumnNode | undefined)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(
|
||||
e,
|
||||
nodeId,
|
||||
useViewer.getState().selection.selectedIds,
|
||||
setSelection,
|
||||
)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
},
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const defaultName = node?.name || 'Column'
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/column.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
nodeId={nodeId}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||
onMouseEnter={() => setHoveredId(nodeId)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -56,6 +56,7 @@ export function focusTreeNode(nodeId: AnyNodeId) {
|
||||
import { cn } from '../../../../../lib/utils'
|
||||
import { BuildingTreeNode } from './building-tree-node'
|
||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||
import { ColumnTreeNode } from './column-tree-node'
|
||||
import { DoorTreeNode } from './door-tree-node'
|
||||
import { FenceTreeNode } from './fence-tree-node'
|
||||
import { ItemTreeNode } from './item-tree-node'
|
||||
@@ -84,6 +85,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
||||
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
|
||||
case 'ceiling':
|
||||
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'column':
|
||||
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'level':
|
||||
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
||||
case 'slab':
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
type FenceNode,
|
||||
getCatalogMaterialById,
|
||||
getEffectiveRoofSurfaceMaterial,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
|
||||
export type PaintableMaterialTarget = Extract<
|
||||
MaterialTarget,
|
||||
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
|
||||
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
|
||||
>
|
||||
|
||||
export type SingleSurfaceMaterialRole = 'surface'
|
||||
@@ -131,10 +132,9 @@ export function buildStairSurfaceMaterialPatch(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
|
||||
material: MaterialSchema | undefined,
|
||||
materialPreset: string | undefined,
|
||||
): Partial<TNode> {
|
||||
export function buildSingleSurfaceMaterialPatch<
|
||||
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
|
||||
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
|
||||
return {
|
||||
material,
|
||||
materialPreset,
|
||||
@@ -220,6 +220,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
||||
|
||||
if (
|
||||
(selectedNode.type === 'fence' ||
|
||||
selectedNode.type === 'column' ||
|
||||
selectedNode.type === 'slab' ||
|
||||
selectedNode.type === 'ceiling') &&
|
||||
selectedMaterialTarget.role === 'surface'
|
||||
@@ -267,6 +268,10 @@ export function resolvePaintTargetFromSelection(params: {
|
||||
return 'fence'
|
||||
}
|
||||
|
||||
if (selectedNode.type === 'column') {
|
||||
return 'column'
|
||||
}
|
||||
|
||||
if (selectedNode.type === 'slab') {
|
||||
return 'slab'
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type AssetInput,
|
||||
type BuildingNode,
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
type DoorNode,
|
||||
type FenceNode,
|
||||
type ItemNode,
|
||||
@@ -139,6 +140,7 @@ type EditorState = {
|
||||
| DoorNode
|
||||
| FenceNode
|
||||
| CeilingNode
|
||||
| ColumnNode
|
||||
| SlabNode
|
||||
| WallNode
|
||||
| RoofNode
|
||||
@@ -155,6 +157,7 @@ type EditorState = {
|
||||
| DoorNode
|
||||
| FenceNode
|
||||
| CeilingNode
|
||||
| ColumnNode
|
||||
| SlabNode
|
||||
| WallNode
|
||||
| RoofNode
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import { type AnyNode, useScene } from '@pascal-app/core'
|
||||
import { BuildingRenderer } from './building/building-renderer'
|
||||
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
||||
import { ColumnRenderer } from './column/column-renderer'
|
||||
import { DoorRenderer } from './door/door-renderer'
|
||||
import { FenceRenderer } from './fence/fence-renderer'
|
||||
import { GuideRenderer } from './guide/guide-renderer'
|
||||
@@ -30,6 +31,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
||||
{node.type === 'site' && <SiteRenderer node={node} />}
|
||||
{node.type === 'building' && <BuildingRenderer node={node} />}
|
||||
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
|
||||
{node.type === 'column' && <ColumnRenderer node={node} />}
|
||||
{node.type === 'level' && <LevelRenderer node={node} />}
|
||||
{node.type === 'item' && <ItemRenderer node={node} />}
|
||||
{node.type === 'slab' && <SlabRenderer node={node} />}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
type ColumnNode,
|
||||
emitter,
|
||||
type ItemNode,
|
||||
type LevelNode,
|
||||
@@ -32,6 +33,7 @@ type SelectableNodeType =
|
||||
| 'fence'
|
||||
| 'window'
|
||||
| 'door'
|
||||
| 'column'
|
||||
| 'item'
|
||||
| 'slab'
|
||||
| 'ceiling'
|
||||
@@ -132,6 +134,11 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean =
|
||||
return pointInPolygonWithTolerance(item.position[0], item.position[2], zone.polygon)
|
||||
}
|
||||
|
||||
if (node.type === 'column') {
|
||||
const column = node as ColumnNode
|
||||
return pointInPolygonWithTolerance(column.position[0], column.position[2], zone.polygon)
|
||||
}
|
||||
|
||||
if (node.type === 'wall') {
|
||||
const wall = node as WallNode
|
||||
const startIn = pointInPolygonWithTolerance(wall.start[0], wall.start[1], zone.polygon)
|
||||
@@ -227,9 +234,20 @@ const getStrategy = (): SelectionStrategy | null => {
|
||||
}
|
||||
}
|
||||
|
||||
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
|
||||
// Zone selected -> can select/hover contents (walls, items, columns, slabs, ceilings, roofs, windows, doors)
|
||||
return {
|
||||
types: ['wall', 'fence', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
|
||||
types: [
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
'roof-segment',
|
||||
'window',
|
||||
'door',
|
||||
],
|
||||
handleClick: (node, nativeEvent) => {
|
||||
let nodeToSelect = node
|
||||
if (node.type === 'roof-segment' && node.parentId) {
|
||||
@@ -258,6 +276,7 @@ const getStrategy = (): SelectionStrategy | null => {
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
@@ -318,6 +337,7 @@ export const SelectionManager = () => {
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
type BuildingNode,
|
||||
type CeilingEvent,
|
||||
type CeilingNode,
|
||||
type ColumnEvent,
|
||||
type ColumnNode,
|
||||
type DoorEvent,
|
||||
type DoorNode,
|
||||
type EventSuffix,
|
||||
@@ -48,6 +50,7 @@ type NodeConfig = {
|
||||
slab: { node: SlabNode; event: SlabEvent }
|
||||
spawn: { node: SpawnNode; event: SpawnEvent }
|
||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||
column: { node: ColumnNode; event: ColumnEvent }
|
||||
roof: { node: RoofNode; event: RoofEvent }
|
||||
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
|
||||
stair: { node: StairNode; event: StairEvent }
|
||||
|
||||
Reference in New Issue
Block a user