diff --git a/apps/editor/public/icons/column.png b/apps/editor/public/icons/column.png index 8e44f6d8..0e337b7f 100644 Binary files a/apps/editor/public/icons/column.png and b/apps/editor/public/icons/column.png differ diff --git a/apps/editor/public/icons/column1.png b/apps/editor/public/icons/column1.png new file mode 100644 index 00000000..8e44f6d8 Binary files /dev/null and b/apps/editor/public/icons/column1.png differ diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7530b30e..8a5c0bf6 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -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 export type SlabEvent = NodeEvent export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent +export type ColumnEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent export type StairEvent = NodeEvent @@ -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> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index ec727481..62efe95d 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -13,6 +13,7 @@ export const sceneRegistry = { site: new Set(), building: new Set(), ceiling: new Set(), + column: new Set(), level: new Set(), wall: new Set(), fence: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59e84f2b..50dad336 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ export type { CameraControlEvent, CameraControlFitSceneEvent, CeilingEvent, + ColumnEvent, DoorEvent, EventSuffix, FenceEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index cfb464c5..ed182320 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -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, diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 846140ef..e2e76ec5 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -46,6 +46,7 @@ export const MaterialTarget = z.enum([ 'stair', 'stair-segment', 'fence', + 'column', 'slab', 'ceiling', 'door', diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts new file mode 100644 index 00000000..d19f725d --- /dev/null +++ b/packages/core/src/schema/nodes/column.ts @@ -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 +export type ColumnCrossSection = z.infer +export type ColumnShaftProfile = z.infer +export type ColumnShaftDetail = z.infer +export type ColumnPanelShape = z.infer +export type ColumnBaseStyle = z.infer +export type ColumnCapitalStyle = z.infer +export type ColumnRingPlacement = z.infer +export type ColumnCarvingPlacement = z.infer + +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>> + +export type ColumnPresetId = keyof typeof COLUMN_PRESETS + +export type ColumnNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index c1d7f371..0b24763a 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -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, diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 00e07fa1..a4977b5d 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -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, diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index fec5d288..6aa84cf0 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -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 diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index b42ee1c8..71f278cf 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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 = { 'wall', 'fence', 'item', + 'column', 'zone', 'slab', 'ceiling', @@ -595,6 +618,7 @@ const SELECTION_STRATEGIES: Record = { 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( - 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', diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx new file mode 100644 index 00000000..9a239f10 --- /dev/null +++ b/packages/editor/src/components/tools/column/column-tool.tsx @@ -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 + Column +) + +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 = ({ currentLevelId }) => { + const [, setCursorPosition] = useState<[number, number, number] | null>(null) + const cursorRef = useRef(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 ( + + ) +} diff --git a/packages/editor/src/components/tools/column/move-column-tool.tsx b/packages/editor/src/components/tools/column/move-column-tool.tsx new file mode 100644 index 00000000..ae02e102 --- /dev/null +++ b/packages/editor/src/components/tools/column/move-column-tool.tsx @@ -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 +} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index b6c1397a..8556d4e7 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -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 if (movingNode.type === 'fence') return if (movingNode.type === 'ceiling') return + if (movingNode.type === 'column') return if (movingNode.type === 'slab') return if (movingNode.type === 'wall') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 0938a254..a0205ae5 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -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' && ( )} - {!movingNode && BuildToolComponent && } + {!movingNode && showBuildTool && tool === 'column' && ( + + )} + {!movingNode && BuildToolComponent && tool !== 'column' && } ) diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 21937885..cc060af6 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -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' }, diff --git a/packages/editor/src/components/ui/panels/column-panel.tsx b/packages/editor/src/components/ui/panels/column-panel.tsx new file mode 100644 index 00000000..e4ae296a --- /dev/null +++ b/packages/editor/src/components/ui/panels/column-panel.tsx @@ -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 { + const { label, ...preset } = COLUMN_PRESETS[presetId] + return { + name: label, + ...preset, + } +} + +function proportionUpdates( + node: ColumnNode, + presetId: ColumnProportionPresetId, +): Partial { + 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 { + 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) => { + 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 ( + + + + + + + + handleUpdate({ edgeSoftness: value })} + precision={3} + step={0.005} + unit="m" + value={node.edgeSoftness ?? 0.025} + /> + {(node.crossSection === 'square' || node.crossSection === 'rectangular') && ( + handleUpdate({ shaftCornerRadius: value })} + precision={3} + step={0.005} + unit="m" + value={node.shaftCornerRadius ?? 0.035} + /> + )} + + + + + handleUpdate({ height: value })} + precision={2} + step={0.05} + unit="m" + value={node.height} + /> + + handleUpdate({ + width: value, + radius: value / 2, + ...(node.crossSection === 'rectangular' ? {} : { depth: value }), + }) + } + precision={2} + step={0.02} + unit="m" + value={node.width} + /> + {node.crossSection === 'rectangular' && ( + handleUpdate({ depth: value })} + precision={2} + step={0.02} + unit="m" + value={node.depth} + /> + )} + + + + + {shaftProfile === 'straight' && ( + handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.72} + /> + )} + {shaftProfile === 'tapered' && ( + <> + handleUpdate({ shaftStartScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.82} + /> + handleUpdate({ shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftEndScale ?? 0.72} + /> + handleUpdate({ shaftTaper: value })} + precision={2} + step={0.01} + value={node.shaftTaper ?? 0.14} + /> + + )} + {shaftProfile === 'bulged' && ( + <> + handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.68} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + {shaftProfile === 'hourglass' && ( + <> + handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.84} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + + 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 && ( + handleUpdate({ ringThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.ringThickness ?? 0.055} + /> + )} + {(node.ringCount ?? 0) > 0 && ( + handleUpdate({ ringSpread: value, ringPlacement: 'ends' })} + precision={2} + step={0.01} + value={node.ringSpread ?? 0.16} + /> + )} + + + + + {node.capitalStyle !== 'none' && ( + handleUpdate({ capitalHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.capitalHeight} + /> + )} + {node.capitalStyle !== 'none' && ( + + handleUpdate({ + capitalWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ capitalDepthScale: value })} + precision={2} + step={0.02} + value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.capitalTierCount ?? 3} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalStepSpread: value })} + precision={2} + step={0.01} + value={node.capitalStepSpread ?? 0.34} + /> + )} + + {node.baseStyle !== 'none' && ( + handleUpdate({ baseHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.baseHeight} + /> + )} + {node.baseStyle !== 'none' && ( + + handleUpdate({ + baseWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ baseDepthScale: value })} + precision={2} + step={0.02} + value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ basePlinthHeightRatio: value })} + precision={2} + step={0.01} + value={node.basePlinthHeightRatio ?? 0.44} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseRoundBandScale: value })} + precision={2} + step={0.01} + value={node.baseRoundBandScale ?? 0.92} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseNeckScale: value })} + precision={2} + step={0.01} + value={node.baseNeckScale ?? 0.72} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.baseTierCount ?? 3} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseStepSpread: value })} + precision={2} + step={0.01} + value={node.baseStepSpread ?? 0.34} + /> + )} + + + + handleUpdate({ rotation: (value * Math.PI) / 180 })} + precision={0} + step={1} + unit="°" + value={Math.round((node.rotation * 180) / Math.PI)} + /> + + + + + } label="Move" onClick={handleMove} /> + } + label="Delete" + onClick={handleDelete} + /> + + + + ) +} diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index 6cc26c66..3b545680 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -12,6 +12,7 @@ const TYPE_DEFAULTS: Record = { 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' }, diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index d5faef6f..e948ab80 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -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([ 'window', 'door', 'ceiling', + 'column', 'slab', 'wall', 'fence', @@ -90,6 +94,8 @@ function panelForType(type: string | null) { return case 'ceiling': return + case 'column': + return case 'wall': return case 'fence': @@ -250,6 +256,8 @@ export function PanelManager() { return case 'ceiling': return + case 'column': + return case 'wall': return case 'fence': diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx new file mode 100644 index 00000000..af37aaf1 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx @@ -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 ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 4a4d9194..2e7ba5df 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -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 case 'ceiling': return + case 'column': + return case 'level': return case 'slab': diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index ffdde786..37582b0d 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -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( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { +export function buildSingleSurfaceMaterialPatch< + TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode, +>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial { return { material, materialPreset, @@ -170,7 +170,7 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'wall', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, sourceTarget: 'wall', @@ -190,7 +190,7 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'roof', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, sourceTarget: 'roof', @@ -210,7 +210,7 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'stair', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, sourceTarget: 'stair', @@ -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' } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 560dc094..b3aa2616 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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 diff --git a/packages/viewer/src/components/renderers/column/column-renderer.tsx b/packages/viewer/src/components/renderers/column/column-renderer.tsx new file mode 100644 index 00000000..1d7932dc --- /dev/null +++ b/packages/viewer/src/components/renderers/column/column-renderer.tsx @@ -0,0 +1,1302 @@ +import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' +import { createContext, useContext, useMemo, useRef } from 'react' +import type { Group, Material } from 'three' +import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' +import { useNodeEvents } from '../../../hooks/use-node-events' +import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' + +const ColumnMaterialContext = createContext(baseMaterial as Material) +const ColumnEdgeSoftnessContext = createContext(0.025) + +function ColumnMaterial() { + const material = useContext(ColumnMaterialContext) + return +} + +function createColumnMaterial({ + material, + materialPreset, +}: Pick) { + const presetMaterial = createMaterialFromPresetRef(materialPreset) + if (presetMaterial) return presetMaterial + if (material) return createMaterial(material) + return baseMaterial +} + +function getSegments(node: ColumnNode) { + if (node.crossSection === 'octagonal') return 8 + if (node.crossSection === 'sixteen-sided') return 16 + return 32 +} + +function getShaftProfile(node: ColumnNode) { + return node.shaftProfile ?? (node.shaftTaper > 0 ? 'tapered' : 'straight') +} + +function getShaftSegmentCount(node: ColumnNode) { + const shaftProfile = getShaftProfile(node) + const shaftTaper = node.shaftTaper ?? 0 + return Math.max( + 1, + shaftProfile === 'straight' && shaftTaper <= 0 ? 1 : (node.shaftSegmentCount ?? 24), + ) +} + +function getShaftScaleAt(node: ColumnNode, t: number) { + const shaftProfile = getShaftProfile(node) + const shaftTaper = Math.min(node.shaftTaper ?? 0, 0.85) + const startScale = node.shaftStartScale ?? 0.72 + const endScale = node.shaftEndScale ?? startScale + const shaftBulge = + node.shaftBulge ?? + (shaftProfile === 'bulged' + ? 0.16 + : shaftProfile === 'baluster' + ? 0.2 + : shaftProfile === 'hourglass' + ? 0.18 + : 0) + const taperedScale = 1 - shaftTaper * t + const linearScale = (startScale + (endScale - startScale) * t) * taperedScale + const bulgeCurve = Math.sin(Math.PI * t) + const hourglassCurve = Math.abs(t - 0.5) * 2 + const profileScale = + shaftProfile === 'bulged' || shaftProfile === 'baluster' + ? linearScale + shaftBulge * bulgeCurve + : shaftProfile === 'hourglass' + ? linearScale - shaftBulge * (1 - hourglassCurve) + : linearScale + + return Math.max(0.1, profileScale) +} + +function SquareBlock({ + y, + height, + width, + depth, + softenEdges = true, +}: { + y: number + height: number + width: number + depth: number + softenEdges?: boolean +}) { + const edgeSoftness = useContext(ColumnEdgeSoftnessContext) + const minDimension = Math.max(0, Math.min(width, height, depth)) + const bevelRadius = softenEdges ? Math.min(Math.max(0, edgeSoftness), minDimension * 0.35) : 0 + const roundedGeometry = useMemo(() => { + if (bevelRadius <= 0.001) return null + return new RoundedBoxGeometry(width, height, depth, 3, bevelRadius) + }, [bevelRadius, depth, height, width]) + + if (height <= 0) return null + + const position = [0, y + height / 2, 0] as const + + return ( + + {roundedGeometry ? ( + + ) : ( + + )} + + + ) +} + +function RoundBlock({ + x = 0, + y, + z = 0, + height, + radius, + segments = 32, +}: { + x?: number + y: number + z?: number + height: number + radius: number + segments?: number +}) { + if (height <= 0) return null + + return ( + + + + + ) +} + +function RoundedRectangleShaftSegment({ + y, + height, + width, + depth, + cornerRadius, +}: { + y: number + height: number + width: number + depth: number + cornerRadius: number +}) { + if (height <= 0) return null + + const radius = Math.min(Math.max(0, cornerRadius), Math.min(width, depth) * 0.45) + if (radius <= 0.001) { + return + } + + const innerWidth = Math.max(0, width - radius * 2) + const innerDepth = Math.max(0, depth - radius * 2) + const cornerX = width / 2 - radius + const cornerZ = depth / 2 - radius + + return ( + + {innerWidth > 0 && ( + + )} + {innerDepth > 0 && ( + + )} + {( + [ + [cornerX, cornerZ], + [cornerX, -cornerZ], + [-cornerX, cornerZ], + [-cornerX, -cornerZ], + ] satisfies [number, number][] + ).map(([x, z], index) => ( + + ))} + + ) +} + +function OvalBlock({ + y, + height, + width, + depth, + segments = 32, +}: { + y: number + height: number + width: number + depth: number + segments?: number +}) { + if (height <= 0) return null + + return ( + + + + + ) +} + +function ColumnBlock({ + node, + y, + height, + scale = 1, +}: { + node: ColumnNode + y: number + height: number + scale?: number +}) { + if (height <= 0) return null + + const width = node.width * scale + const depth = node.depth * scale + const radius = node.radius * scale + + if (node.crossSection === 'square' || node.crossSection === 'rectangular') { + return + } + + return +} + +function TaperedRoundShaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) { + const segmentCount = getShaftSegmentCount(node) + const segmentHeight = height / segmentCount + + return ( + + {Array.from({ length: segmentCount }, (_, index) => { + const t = (index + 0.5) / segmentCount + const profileScale = getShaftScaleAt(node, t) + return ( + + ) + })} + + ) +} + +function TaperedSquareShaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) { + const segmentCount = getShaftSegmentCount(node) + const segmentHeight = height / segmentCount + + return ( + + {Array.from({ length: segmentCount }, (_, index) => { + const t = (index + 0.5) / segmentCount + const profileScale = getShaftScaleAt(node, t) + return ( + + ) + })} + + ) +} + +function Shaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) { + if (height <= 0) return null + + if (node.style === 'cluster') { + const sideRadius = Math.max(0.04, node.radius * 0.36) + const offset = Math.max(node.radius * 0.78, node.width * 0.22) + return ( + + + + + + {( + [ + [offset, 0], + [-offset, 0], + [0, offset], + [0, -offset], + ] satisfies [number, number][] + ).map(([x, z], index) => ( + + + + + ))} + + ) + } + + if ( + node.crossSection === 'round' || + node.crossSection === 'octagonal' || + node.crossSection === 'sixteen-sided' + ) { + return + } + + return +} + +function Base({ node, height }: { node: ColumnNode; height: number }) { + if (height <= 0) return null + + const baseStyle = node.baseStyle ?? 'round-rings' + const widthScale = node.baseWidthScale ?? 1.24 + const depthScale = node.baseDepthScale ?? widthScale + + if (baseStyle === 'none') return null + + if (baseStyle === 'simple-square') { + return ( + + ) + } + + if (baseStyle === 'square-plinth') { + return ( + + + + + ) + } + + if (baseStyle === 'stepped-square') { + const tierCount = Math.max(3, node.baseTierCount ?? 3) + const tierHeight = height / tierCount + const stepSpread = node.baseStepSpread ?? 0.42 + return ( + + {Array.from({ length: tierCount }, (_, index) => { + const t = index / Math.max(1, tierCount - 1) + const widthScaleAt = Math.max(0.5, widthScale - t * stepSpread) + const depthScaleAt = Math.max(0.5, depthScale - t * stepSpread) + return ( + + ) + })} + + ) + } + + if (baseStyle === 'round-rings') { + const baseWidth = node.width * widthScale + const baseDepth = node.depth * depthScale + const plinthRatio = Math.min(0.7, Math.max(0.2, node.basePlinthHeightRatio ?? 0.44)) + const plinthHeight = height * plinthRatio + const roundedHeight = height - plinthHeight + const bandHeight = roundedHeight * 0.57 + const neckHeight = roundedHeight - bandHeight + const bandScale = node.baseRoundBandScale ?? 0.92 + const neckScale = node.baseNeckScale ?? 0.72 + return ( + + + + + + ) + } + + if (baseStyle === 'lotus' || baseStyle === 'ribbed-lotus') { + const ribCount = node.baseRibCount ?? (baseStyle === 'ribbed-lotus' ? 24 : 14) + const ribRadius = Math.max(0.01, node.width * 0.025) + const baseRadius = Math.max(node.radius * widthScale, node.width * widthScale * 0.5) + return ( + + + + {Array.from({ length: ribCount }, (_, index) => { + const angle = (index / ribCount) * Math.PI * 2 + return ( + + + + + ) + })} + + + ) + } + + if (baseStyle === 'panelled-pedestal') { + const inset = node.basePanelInset ?? 0.02 + return ( + + + {( + [ + [0, node.depth * widthScale * 0.51, 0], + [0, -node.depth * widthScale * 0.51, 0], + [node.width * widthScale * 0.51, 0, Math.PI / 2], + [-node.width * widthScale * 0.51, 0, Math.PI / 2], + ] satisfies [number, number, number][] + ).map(([x, z, rotation], index) => ( + + + + + ))} + + ) + } + + return +} + +function BaseCarvings({ node, height }: { node: ColumnNode; height: number }) { + const placement = node.carvingPlacement ?? 'capital' + const carvingLevel = node.baseCarvingLevel ?? 0 + if (carvingLevel <= 0 || height <= 0 || (placement !== 'base' && placement !== 'all')) { + return null + } + + const count = Math.max(8, carvingLevel * 8) + const radius = Math.max(node.radius * 1.04, Math.max(node.width, node.depth) * 0.5) + const y = height * 0.52 + + return ( + + {Array.from({ length: count }, (_, index) => { + const angle = (index / count) * Math.PI * 2 + return ( + + + + + ) + })} + + ) +} + +function Rings({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + if (node.ringCount <= 0 || shaftHeight <= 0) return null + + const ringPlacement = node.ringPlacement ?? 'ends' + const ringSpread = Math.min(0.45, Math.max(0.04, node.ringSpread ?? 0.16)) + const ringHeight = Math.min( + node.ringThickness ?? 0.055, + shaftHeight / Math.max(8, node.ringCount * 3), + ) + const rings = Array.from({ length: node.ringCount }, (_, index) => { + const pairIndex = Math.floor(index / 2) + const nearTop = index % 2 === 1 + const pairCount = Math.ceil(node.ringCount / 2) + const pairT = pairCount <= 1 ? 0 : pairIndex / (pairCount - 1) + const offset = Math.min(0.48, 0.06 + pairT * Math.max(0, ringSpread - 0.06)) + const oneSideT = + 0.06 + (index / Math.max(1, node.ringCount - 1)) * Math.max(0, ringSpread - 0.06) + const t = + ringPlacement === 'even' + ? (index + 1) / (node.ringCount + 1) + : ringPlacement === 'top' + ? 1 - Math.min(0.48, oneSideT) + : ringPlacement === 'bottom' + ? Math.min(0.48, oneSideT) + : nearTop + ? 1 - offset + : offset + return { + scale: Math.min(1.4, getShaftScaleAt(node, t) + 0.12), + y: shaftY + shaftHeight * t - ringHeight / 2, + } + }).sort((a, b) => a.y - b.y) + + return ( + + {rings.map((ring, index) => ( + + ))} + + ) +} + +function LatheBands({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const latheRingCount = Math.max( + node.latheRingCount ?? 0, + node.shaftDetail === 'lathe-turned' ? 8 : 0, + ) + if (latheRingCount <= 0 || shaftHeight <= 0) return null + + const placement = node.latheRingSpacing ?? 'ends' + const bandHeight = Math.min(0.04, shaftHeight / Math.max(12, latheRingCount * 3)) + const bands = Array.from({ length: latheRingCount }, (_, index) => { + const pairIndex = Math.floor(index / 2) + const nearTop = index % 2 === 1 + const offset = Math.min(0.48, 0.1 + pairIndex * 0.04) + const t = + placement === 'even' + ? (index + 1) / (latheRingCount + 1) + : placement === 'top' + ? 1 - Math.min(0.48, 0.08 + index * 0.04) + : placement === 'bottom' + ? Math.min(0.48, 0.08 + index * 0.04) + : nearTop + ? 1 - offset + : offset + return shaftY + shaftHeight * t - bandHeight / 2 + }).sort((a, b) => a - b) + + return ( + + {bands.map((y, index) => ( + + ))} + + ) +} + +function Flutes({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const fluteCount = Math.max(node.fluteCount, node.shaftDetail === 'fluted' ? 16 : 0) + if (fluteCount <= 0 || shaftHeight <= 0 || node.crossSection !== 'round') return null + + const fluteDepth = node.fluteDepth ?? 0.02 + const fluteWidth = node.fluteWidth ?? fluteDepth + const fluteRadius = Math.max(0.006, fluteWidth * 0.42) + const shaftRadius = node.radius * 0.74 + + return ( + + {Array.from({ length: fluteCount }, (_, index) => { + const angle = (index / fluteCount) * Math.PI * 2 + const x = Math.cos(angle) * shaftRadius + const z = Math.sin(angle) * shaftRadius + return ( + + + + + ) + })} + + ) +} + +function DravidianShaftPanels({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const panelCount = Math.max( + node.panelCount ?? 0, + node.style === 'dravidian-carved' || node.shaftDetail === 'panelled' ? 3 : 0, + ) + if (panelCount <= 0 || shaftHeight <= 0) return null + + const shaftWidth = node.width * 0.72 + const shaftDepth = node.depth * 0.72 + const panelHeight = Math.min(0.42, shaftHeight / Math.max(4, panelCount + 2)) + const panelWidth = node.width * 0.26 + const rail = Math.max(0.012, node.width * 0.028) + const reliefDepth = Math.max(0.012, node.panelInsetDepth ?? node.width * 0.025) + const rows = Array.from({ length: panelCount }, (_, index) => (index + 1) / (panelCount + 1)) + const panelShape = node.panelShape ?? 'rectangle' + + const PanelFace = ({ + position, + rotation = 0, + }: { + position: [number, number, number] + rotation?: number + }) => ( + + + + + + + + + + + + + + + + + + {panelShape === 'diamond' && ( + + + + + )} + {panelShape === 'arched' && ( + + + + + )} + + ) + + return ( + + {rows.map((t, rowIndex) => { + const y = shaftY + shaftHeight * t + return ( + + + + + + + ) + })} + + ) +} + +function SpiralRibs({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const spiralRibCount = node.spiralRibCount ?? 0 + const spiralTwist = node.spiralTwist ?? 0 + const shaftTaper = node.shaftTaper ?? 0 + const ribCountSetting = Math.max(spiralRibCount, node.shaftDetail === 'spiral' ? 12 : 0) + if (ribCountSetting <= 0 || spiralTwist === 0 || shaftHeight <= 0) return null + + const ribCount = Math.min(ribCountSetting, 24) + const stepCount = 28 + const ribDistance = node.radius * 0.78 + const ribWidth = Math.max(0.012, node.radius * 0.06) + const segmentHeight = (shaftHeight / stepCount) * 1.18 + const lean = spiralTwist > 0 ? -0.55 : 0.55 + + return ( + + {Array.from({ length: ribCount * stepCount }, (_, index) => { + const ribIndex = index % ribCount + const stepIndex = Math.floor(index / ribCount) + const t = (stepIndex + 0.5) / stepCount + const angle = (ribIndex / ribCount) * Math.PI * 2 + t * spiralTwist * Math.PI * 2 + const taperScale = 1 - Math.min(shaftTaper, 0.85) * t + return ( + + + + + ) + })} + + ) +} + +function LowerCarvedBand({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const placement = node.carvingPlacement ?? 'capital' + if ( + !node.lowerBandEnabled || + shaftHeight <= 0 || + (placement !== 'shaft' && placement !== 'all') + ) { + return null + } + + const bandHeight = Math.min(node.lowerBandHeight ?? 0.24, shaftHeight * 0.35) + const y = shaftY + shaftHeight * 0.12 + const level = Math.max(1, node.lowerBandCarvingLevel ?? 1) + const count = Math.max(6, level * 6) + const distance = Math.max(node.radius * 0.82, Math.max(node.width, node.depth) * 0.36) + + return ( + + + {Array.from({ length: count }, (_, index) => { + const angle = (index / count) * Math.PI * 2 + return ( + + + + + ) + })} + + ) +} + +function CapitalCarvings({ + node, + capitalY, + capitalHeight, +}: { + node: ColumnNode + capitalY: number + capitalHeight: number +}) { + const placement = node.carvingPlacement ?? 'capital' + const carvingLevel = Math.max(node.carvingLevel ?? 0, node.capitalCarvingLevel ?? 0) + const bandSetting = node.capitalBandCount ?? 0 + if ( + (carvingLevel <= 0 && bandSetting <= 0) || + capitalHeight <= 0 || + (placement !== 'capital' && placement !== 'all') + ) { + return null + } + + const level = Math.min(Math.max(carvingLevel, bandSetting > 0 ? 1 : 0), 4) + const bandHeight = Math.min(0.035, capitalHeight / 8) + const bandCount = Math.min(bandSetting > 0 ? bandSetting : level + 1, 16) + const bands = Array.from({ length: bandCount }, (_, index) => { + const t = (index + 1) / (bandCount + 1) + return capitalY + capitalHeight * t - bandHeight / 2 + }) + + if (node.crossSection === 'square' || node.crossSection === 'rectangular') { + const dentilCount = Math.max(node.dentilCount ?? 0, level * 4, 4) + const dentilHeight = Math.min(0.08, capitalHeight * 0.28) + const dentilDepth = Math.min(0.08, Math.min(node.width, node.depth) * 0.16) + const dentilWidth = Math.max(0.025, node.width / (dentilCount * 1.75)) + const halfWidth = node.width * 0.56 + const halfDepth = node.depth * 0.56 + const y = capitalY + capitalHeight * 0.28 + const xPositions = Array.from({ length: dentilCount }, (_, index) => { + const t = dentilCount === 1 ? 0.5 : index / (dentilCount - 1) + return -halfWidth + t * halfWidth * 2 + }) + const zPositions = Array.from({ length: dentilCount }, (_, index) => { + const t = dentilCount === 1 ? 0.5 : index / (dentilCount - 1) + return -halfDepth + t * halfDepth * 2 + }) + + return ( + + {bands.map((bandY, index) => ( + + ))} + {xPositions.map((x, index) => ( + + + + + + + + + + + ))} + {zPositions.map((z, index) => ( + + + + + + + + + + + ))} + + ) + } + + const beadCount = Math.max(node.beadCount ?? 0, 8, level * 8) + const beadRadius = Math.max(0.012, Math.min(0.03, node.radius * 0.12)) + const beadDistance = node.radius * 1.24 + const beadY = capitalY + capitalHeight * 0.24 + + return ( + + {bands.map((bandY, index) => ( + + ))} + {Array.from({ length: beadCount }, (_, index) => { + const angle = (index / beadCount) * Math.PI * 2 + return ( + + + + + ) + })} + + ) +} + +function Volutes({ + node, + capitalY, + capitalHeight, +}: { + node: ColumnNode + capitalY: number + capitalHeight: number +}) { + if (!['volute', 'ionic-volute'].includes(node.capitalStyle ?? 'simple') || capitalHeight <= 0) + return null + + const y = capitalY + capitalHeight * 0.62 + const radius = node.voluteSize ?? Math.min(0.085, Math.max(0.04, node.width * 0.12)) + const x = node.width * 0.46 + const z = node.depth * 0.7 + const maxVolutes = Math.max(0, Math.min(node.voluteCount ?? 4, 8)) + const volutes = [ + { + position: [x, y, z] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + { + position: [-x, y, z] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + { + position: [x, y, -z] as [number, number, number], + rotation: [0, Math.PI, 0] as [number, number, number], + }, + { + position: [-x, y, -z] as [number, number, number], + rotation: [0, Math.PI, 0] as [number, number, number], + }, + { + position: [z, y, x] as [number, number, number], + rotation: [0, Math.PI / 2, 0] as [number, number, number], + }, + { + position: [z, y, -x] as [number, number, number], + rotation: [0, Math.PI / 2, 0] as [number, number, number], + }, + { + position: [-z, y, x] as [number, number, number], + rotation: [0, -Math.PI / 2, 0] as [number, number, number], + }, + { + position: [-z, y, -x] as [number, number, number], + rotation: [0, -Math.PI / 2, 0] as [number, number, number], + }, + ].slice(0, maxVolutes) + + return ( + + {volutes.map((volute, index) => ( + + + + + ))} + + ) +} + +function LeafCarvings({ + node, + capitalY, + capitalHeight, +}: { + node: ColumnNode + capitalY: number + capitalHeight: number +}) { + if ( + !['leaf-carved', 'corinthian-leaf'].includes(node.capitalStyle ?? 'simple') || + capitalHeight <= 0 + ) { + return null + } + + const leafCount = node.leafCount ?? (node.crossSection === 'round' ? 18 : 12) + const distance = Math.max(node.radius * 1.05, Math.max(node.width, node.depth) * 0.48) + const rowCount = Math.max(0, Math.min(node.leafRows ?? 2, 4)) + const rows = Array.from({ length: rowCount }, (_, index) => ({ + y: capitalY + capitalHeight * (0.3 + index * 0.16), + scale: 0.28 - index * 0.04, + offset: index % 2 === 0 ? 0 : Math.PI / leafCount, + })) + + return ( + + {rows.flatMap((row, rowIndex) => + Array.from({ length: leafCount }, (_, index) => { + const angle = (index / leafCount) * Math.PI * 2 + row.offset + return ( + + + + + ) + }), + )} + + ) +} + +function Capital({ node, y, height }: { node: ColumnNode; y: number; height: number }) { + if (height <= 0) return null + + const capitalStyle = node.capitalStyle ?? 'simple' + if (capitalStyle === 'none') return null + + if (capitalStyle === 'south-indian-bracket' || capitalStyle === 'wood-bracket') { + const tierCount = Math.max(1, node.bracketTierCount ?? 3) + const tierHeight = height / tierCount + const bracketDepth = node.bracketDepth ?? 0.35 + return ( + + {Array.from({ length: tierCount }, (_, index) => { + const t = index / Math.max(1, tierCount - 1) + const scale = (node.capitalWidthScale ?? 1.6) + t * 0.32 + return ( + + ) + })} + {Array.from({ length: node.pendantCount ?? 0 }, (_, index) => { + const count = Math.max(1, node.pendantCount ?? 0) + const angle = (index / count) * Math.PI * 2 + const distance = Math.max(node.width, node.depth) * 0.56 + return ( + + + + + ) + })} + + ) + } + + if (capitalStyle === 'rounded' || capitalStyle === 'doric') { + const topWidth = node.width * (node.capitalWidthScale ?? 1.34) + const topDepth = node.depth * (node.capitalDepthScale ?? node.capitalWidthScale ?? 1.34) + return ( + + + + + + ) + } + + if (capitalStyle === 'stepped') { + const widthScale = node.capitalWidthScale ?? 1.46 + const depthScale = node.capitalDepthScale ?? widthScale + const tierCount = Math.max(3, node.capitalTierCount ?? 3) + const tierHeight = height / tierCount + const stepSpread = node.capitalStepSpread ?? 0.42 + + return ( + + {Array.from({ length: tierCount }, (_, index) => { + const t = index / Math.max(1, tierCount - 1) + const widthScaleAt = Math.max(0.5, widthScale - (1 - t) * stepSpread) + const depthScaleAt = Math.max(0.5, depthScale - (1 - t) * stepSpread) + return ( + + ) + })} + + ) + } + + if ( + capitalStyle === 'volute' || + capitalStyle === 'ionic-volute' || + capitalStyle === 'leaf-carved' || + capitalStyle === 'corinthian-leaf' + ) { + const topWidth = node.width * (node.capitalWidthScale ?? 1.46) + const topDepth = node.depth * (node.capitalDepthScale ?? node.capitalWidthScale ?? 1.46) + + return ( + + + + + + + + ) + } + + const widthScale = node.capitalWidthScale ?? (capitalStyle === 'simple-slab' ? 1.28 : 1.18) + const depthScale = node.capitalDepthScale ?? widthScale + + if (node.crossSection === 'square' || node.crossSection === 'rectangular') { + return ( + + ) + } + + return ( + + ) +} + +export const ColumnRenderer = ({ node }: { node: ColumnNode }) => { + const ref = useRef(null!) + const handlers = useNodeEvents(node, 'column') + const liveTransform = useLiveTransforms((state) => state.get(node.id)) + const material = useMemo( + () => createColumnMaterial({ material: node.material, materialPreset: node.materialPreset }), + [ + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + node.materialPreset, + ], + ) + + useRegistry(node.id, node.type, ref) + + const shaftLayout = useMemo(() => { + const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4) + const capitalHeight = + node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4) + const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight) + return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight } + }, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height]) + + return ( + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 80eb5b6a..c4b6db97 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -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' && } {node.type === 'building' && } {node.type === 'ceiling' && } + {node.type === 'column' && } {node.type === 'level' && } {node.type === 'item' && } {node.type === 'slab' && } diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index f51c4dd5..9f7adadf 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -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', diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index d0b39b53..18487347 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -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 }