diff --git a/apps/editor/public/icons/column.png b/apps/editor/public/icons/column.png index 8e44f6d8e..0e337b7f1 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 000000000..8e44f6d8e 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 7530b30ec..fa467e9dd 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -4,9 +4,9 @@ import type { Object3D } from 'three' import type { BuildingNode, CeilingNode, + ColumnNode, DoorNode, FenceNode, - GuideNode, ItemNode, LevelNode, RoofNode, @@ -57,6 +57,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 @@ -131,12 +132,6 @@ type ToolEvents = { 'tool:cancel': undefined } -type GuideEvents = { - 'guide:set-reference-scale': { guideId: GuideNode['id'] } - 'guide:cancel-reference-scale': undefined - 'guide:deleted': { guideId: GuideNode['id'] } -} - type PresetEvents = { 'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } @@ -169,6 +164,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> & @@ -177,7 +173,6 @@ type EditorEvents = GridEvents & NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents & - GuideEvents & PresetEvents & ThumbnailEvents & SnapshotEvents & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index ec727481e..62efe95d5 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 59e84f2bc..50dad3366 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 cfb464c56..ed182320a 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 846140ef0..e2e76ec59 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 000000000..a7fa83a8e --- /dev/null +++ b/packages/core/src/schema/nodes/column.ts @@ -0,0 +1,410 @@ +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), + shaftTwistStep: z.number().min(-90).max(90).default(0), + 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 + - shaftTwistStep: per-segment shaft rotation in degrees + - 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, + shaftTwistStep: 0, + 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, + shaftTwistStep: 0, + 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, + shaftTwistStep: 0, + 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, + shaftTwistStep: 0, + 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, + shaftTwistStep: 0, + 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 c1d7f371d..0b24763a0 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 00e07fa19..a4977b5d4 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 fec5d288e..2f696d275 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, type CeilingNode, + ColumnNode, DoorNode, FenceNode, generateId, @@ -25,8 +26,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 +41,7 @@ const ALLOWED_TYPES = [ 'stair-segment', 'wall', 'fence', + 'column', 'slab', 'ceiling', 'spawn', @@ -147,10 +149,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 +185,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' || @@ -263,6 +263,8 @@ export function FloatingActionMenu() { duplicate = WindowNode.parse(duplicateInfo) } else if (node.type === 'item') { duplicate = ItemNode.parse(duplicateInfo) + } else if (node.type === 'column') { + duplicate = ColumnNode.parse(duplicateInfo) } else if (node.type === 'wall') { duplicate = WallNode.parse(duplicateInfo) } else if (node.type === 'fence') { @@ -323,6 +325,7 @@ export function FloatingActionMenu() { } if ( duplicate.type === 'item' || + duplicate.type === 'column' || duplicate.type === 'wall' || duplicate.type === 'fence' || duplicate.type === 'window' || @@ -335,7 +338,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: [] }) } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index bf3b5f9cd..9e20eee14 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -64,6 +64,7 @@ import { rotatePlanVector as rotateSharedPlanVector, type FloorplanNodeTransform as SharedFloorplanNodeTransform, } from '../../lib/floorplan' +import { guideEmitter } from '../../lib/guide-events' import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' @@ -9131,9 +9132,9 @@ export function FloorplanPanel() { } } - emitter.on('guide:set-reference-scale', handleSetReferenceScale) + guideEmitter.on('guide:set-reference-scale', handleSetReferenceScale) return () => { - emitter.off('guide:set-reference-scale', handleSetReferenceScale) + guideEmitter.off('guide:set-reference-scale', handleSetReferenceScale) } }, [startReferenceScaleForGuide]) @@ -9143,9 +9144,9 @@ export function FloorplanPanel() { setPendingReferenceScale(null) } - emitter.on('guide:cancel-reference-scale', handleCancel) + guideEmitter.on('guide:cancel-reference-scale', handleCancel) return () => { - emitter.off('guide:cancel-reference-scale', handleCancel) + guideEmitter.off('guide:cancel-reference-scale', handleCancel) } }, []) @@ -9160,9 +9161,9 @@ export function FloorplanPanel() { clearGuideUi(payload.guideId) } - emitter.on('guide:deleted', handleDeleted) + guideEmitter.on('guide:deleted', handleDeleted) return () => { - emitter.off('guide:deleted', handleDeleted) + guideEmitter.off('guide:deleted', handleDeleted) } }, [clearGuideUi]) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index b42ee1c8e..71f278cf1 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/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index 8c61b407f..60b041ceb 100755 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -4,10 +4,12 @@ import { type AnyNodeId, calculateLevelMiters, DEFAULT_WALL_HEIGHT, + getScaledDimensions, getWallCurveLength, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, + type ItemNode, isCurvedWall, type Point2D, pointToKey, @@ -27,6 +29,8 @@ const GUIDE_Y_OFFSET = 0.08 const LABEL_LIFT = 0.08 const BAR_THICKNESS = 0.012 const LINE_OPACITY = 0.95 +const HEIGHT_TICK_HALF_LENGTH = 0.14 +const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16 const BAR_AXIS = new THREE.Vector3(0, 1, 0) @@ -39,6 +43,18 @@ type MeasurementGuide = { extEndStart: Vec3 extEndEnd: Vec3 labelPosition: Vec3 + heightStart: Vec3 + heightEnd: Vec3 + heightBottomTickStart: Vec3 + heightBottomTickEnd: Vec3 + heightTopTickStart: Vec3 + heightTopTickEnd: Vec3 + heightLabelPosition: Vec3 +} + +type WallFaceLine = { + start: Point2D + end: Point2D } function formatMeasurement(value: number, unit: 'metric' | 'imperial') { @@ -57,28 +73,28 @@ export function WallMeasurementLabel() { const nodes = useScene((state) => state.nodes) const selectedId = selectedIds.length === 1 ? selectedIds[0] : null - const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null - const wall = selectedNode?.type === 'wall' ? selectedNode : null + const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null + const measurableNode = + selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null - const [wallObjectState, setWallObjectState] = useState<{ - id: WallNode['id'] + const [objectState, setObjectState] = useState<{ + id: AnyNodeId object: THREE.Object3D } | null>(null) - const wallObject = - selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null + const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null useFrame(() => { - if (!selectedId || wallObject) return + if (!selectedId || selectedObject) return - const nextWallObject = sceneRegistry.nodes.get(selectedId) - if (nextWallObject) { - setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject }) + const nextObject = sceneRegistry.nodes.get(selectedId) + if (nextObject) { + setObjectState({ id: selectedId as AnyNodeId, object: nextObject }) } }) - if (!(wall && wallObject)) return null + if (!(measurableNode && selectedObject)) return null - return createPortal(, wallObject) + return createPortal(, selectedObject) } function getLevelWalls( @@ -97,6 +113,114 @@ function getLevelWalls( .filter((node): node is WallNode => Boolean(node && node.type === 'wall')) } +function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) { + if (!point) return false + + return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6 +} + +function getWallFaceLines( + wall: WallNode, + miterData: WallMiterData, +): { left: WallFaceLine; right: WallFaceLine } | null { + if (isCurvedWall(wall)) return null + + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 4) return null + + const startRight = footprint[0] + const endRight = footprint[1] + const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end) + const endLeft = footprint[hasEndCenterPoint ? 3 : 2] + const lastPoint = footprint[footprint.length - 1] + const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start) + const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1] + + if (!(startRight && endRight && endLeft && startLeft)) return null + + return { + left: { + start: startLeft, + end: endLeft, + }, + right: { + start: startRight, + end: endRight, + }, + } +} + +function getLineMidpoint(line: WallFaceLine): Point2D { + return { + x: (line.start.x + line.end.x) / 2, + y: (line.start.y + line.end.y) / 2, + } +} + +function getLevelWallsCenter(levelWalls: WallNode[]): Point2D { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const candidateWall of levelWalls) { + minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0]) + maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0]) + minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1]) + maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1]) + } + + return { + x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2, + y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2, + } +} + +function getWallOuterFaceLine( + wall: WallNode, + miterData: WallMiterData, + levelWalls: WallNode[], +): WallFaceLine | null { + const faceLines = getWallFaceLines(wall, miterData) + if (!faceLines) return null + + if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { + return faceLines.left + } + + if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { + return faceLines.right + } + + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return null + + const wallMidpoint = { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + } + const levelCenter = getLevelWallsCenter(levelWalls) + const normal = { x: -dy / length, y: dx / length } + const fromCenter = { + x: wallMidpoint.x - levelCenter.x, + y: wallMidpoint.y - levelCenter.y, + } + const outwardNormal = + fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y } + const rightMidpoint = getLineMidpoint(faceLines.right) + const leftMidpoint = getLineMidpoint(faceLines.left) + const rightScore = + (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x + + (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y + const leftScore = + (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x + + (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y + + return rightScore >= leftScore ? faceLines.right : faceLines.left +} + function getWallMiddlePoints( wall: WallNode, miterData: WallMiterData, @@ -136,7 +260,10 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 { return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA] } -function getWallExteriorOffsetSign(wall: Pick) { +function getWallExteriorOffsetSign( + wall: Pick, + levelWalls: WallNode[], +) { if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { return 1 } @@ -145,10 +272,31 @@ function getWallExteriorOffsetSign(wall: Pick= 0 ? 1 : -1 } -function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null { +function getCurvedWallMeasurementPath( + wall: WallNode, + miterData: WallMiterData, + levelWalls: WallNode[], +): Point2D[] | null { const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData) if (!boundaryPoints) return null @@ -156,7 +304,7 @@ function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): const sidePointCount = 25 if (surface.length < sidePointCount * 2) return null - const offsetSign = getWallExteriorOffsetSign(wall) + const offsetSign = getWallExteriorOffsetSign(wall, levelWalls) if (offsetSign >= 0) { return surface.slice(sidePointCount).reverse() } @@ -170,14 +318,16 @@ function buildMeasurementGuide( ): MeasurementGuide | null { const levelWalls = getLevelWalls(wall, nodes) const miterData = calculateLevelMiters(levelWalls) - const middlePoints = getWallMiddlePoints(wall, miterData) - if (!middlePoints) return null + const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls) + const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData) + const measurementPoints = measurementLine ?? fallbackMiddlePoints + if (!measurementPoints) return null const height = wall.height ?? DEFAULT_WALL_HEIGHT - const startLocal = worldPointToWallLocal(wall, middlePoints.start) - const endLocal = worldPointToWallLocal(wall, middlePoints.end) + const startLocal = worldPointToWallLocal(wall, measurementPoints.start) + const endLocal = worldPointToWallLocal(wall, measurementPoints.end) const curvedMeasurementPath = isCurvedWall(wall) - ? getCurvedWallMeasurementPath(wall, miterData) + ? getCurvedWallMeasurementPath(wall, miterData, levelWalls) : null const guidePath: Vec3[] = curvedMeasurementPath ? curvedMeasurementPath.map((point) => { @@ -224,6 +374,38 @@ function buildMeasurementGuide( guideStart[1], (guideStart[2] + guideEnd[2]) / 2, ] as Vec3) + const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3 + const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart + const tickDx = guideEnd[0] - beforeGuideEnd[0] + const tickDz = guideEnd[2] - beforeGuideEnd[2] + const tickLength = Math.hypot(tickDx, tickDz) + const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1 + const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0 + const tickUnitX = -tangentZ + const tickUnitZ = tangentX + const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] }) + const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0] + const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2] + const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1 + const heightGuidePosition = [ + rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, + 0, + rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, + ] as Vec3 + const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({ + start: [ + heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH, + y, + heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH, + ], + end: [ + heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH, + y, + heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH, + ], + }) + const bottomHeightTick = getHorizontalHeightTick(0) + const topHeightTick = getHorizontalHeightTick(height) return { guidePath, @@ -236,6 +418,37 @@ function buildMeasurementGuide( extEndStart: [extensionEndBase[0], height, extensionEndBase[2]], extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]], labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]], + heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]], + heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]], + heightBottomTickStart: bottomHeightTick.start, + heightBottomTickEnd: bottomHeightTick.end, + heightTopTickStart: topHeightTick.start, + heightTopTickEnd: topHeightTick.end, + heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]], + } +} + +type HeightGuide = { + start: Vec3 + end: Vec3 + labelPosition: Vec3 +} + +function buildItemHeightGuide(item: ItemNode): { guide: HeightGuide; height: number } | null { + const [width, height, depth] = getScaledDimensions(item) + + if (!Number.isFinite(height) || height < 0.01) return null + + const x = Number.isFinite(width) ? width / 2 + 0.18 : 0.18 + const z = Number.isFinite(depth) ? depth / 2 + 0.18 : 0.18 + + return { + height, + guide: { + start: [x, 0, z], + end: [x, height, z], + labelPosition: [x, height / 2, z], + }, } } @@ -286,6 +499,45 @@ function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) { ) } +function MeasurementLabel({ + label, + position, + color, + shadowColor, +}: { + label: string + position: Vec3 + color: string + shadowColor: string +}) { + return ( + +
+ {label} +
+ + ) +} + +function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) { + if (node.type === 'wall') { + return + } + + return +} + function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { const nodes = useScene((state) => state.nodes) const theme = useViewer((state) => state.theme) @@ -316,6 +568,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { return total }, [guide, wall]) const label = formatMeasurement(length, unit) + const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}` if (!(guide && Number.isFinite(length) && length >= 0.01)) return null @@ -324,23 +577,50 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { + + + - -
- {label} -
- + shadowColor={shadowColor} + /> + + + ) +} + +function ItemHeightMeasurementAnnotation({ item }: { item: ItemNode }) { + const theme = useViewer((state) => state.theme) + const unit = useViewer((state) => state.unit) + const isNight = theme === 'dark' + const color = isNight ? '#ffffff' : '#111111' + const shadowColor = isNight ? '#111111' : '#ffffff' + + const measurement = useMemo(() => buildItemHeightGuide(item), [item]) + + if (!measurement) return null + + return ( + + + ) } 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 000000000..e9593d1a0 --- /dev/null +++ b/packages/editor/src/components/tools/column/column-tool.tsx @@ -0,0 +1,97 @@ +import '../../../three-types' + +import { + COLUMN_PRESETS, + ColumnNode, + type ColumnNode as ColumnNodeType, + type ColumnPresetId, + emitter, + type GridEvent, + type LevelNode, + useScene, +} from '@pascal-app/core' +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 + onPlaced?: (nodeId: ColumnNodeType['id']) => void +} + +export const ColumnTool: React.FC = ({ currentLevelId, onPlaced }) => { + 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) + onPlaced?.(column.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, onPlaced]) + + 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 000000000..ae02e102b --- /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/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index 99ad4c06d..4f1fbd8f0 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -1,14 +1,21 @@ -import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core' +import { + FenceNode, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + useScene, + type WallNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { sfxEmitter } from '../../../lib/sfx-bus' import { + findWallSnapTarget, getWallAngleSnapStep, getWallGridStep, - type WallPlanPoint, - findWallSnapTarget, isWallLongEnough, snapPointTo45Degrees, snapPointToGrid, + type WallPlanPoint, } from '../wall/wall-drafting' export type FencePlanPoint = WallPlanPoint diff --git a/packages/editor/src/components/tools/fence/fence-tool.tsx b/packages/editor/src/components/tools/fence/fence-tool.tsx index d7091fd1b..c52f9b413 100644 --- a/packages/editor/src/components/tools/fence/fence-tool.tsx +++ b/packages/editor/src/components/tools/fence/fence-tool.tsx @@ -7,19 +7,129 @@ import { type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' +import { + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from '../shared/segment-angle' import { createFenceOnCurrentLevel, - snapFenceDraftPoint, type FencePlanPoint, + snapFenceDraftPoint, } from './fence-drafting' const FENCE_PREVIEW_HEIGHT = 1.8 +const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22 +const DRAFT_ANGLE_LABEL_Y = 0.28 + +type DraftAngleLabel = { + id: string + label: string + position: [number, number, number] +} + +type DraftMeasurementState = { + lengthLabel: string + lengthPosition: [number, number, number] + angleLabels: DraftAngleLabel[] +} | null + +type SegmentLike = { + id: string + start: FencePlanPoint + end: FencePlanPoint + curveOffset?: number +} + +function formatMeasurement(value: number, unit: 'metric' | 'imperial') { + if (unit === 'imperial') { + const feet = value * 3.280_84 + const wholeFeet = Math.floor(feet) + const inches = Math.round((feet - wholeFeet) * 12) + if (inches === 12) return `${wholeFeet + 1}'0"` + return `${wholeFeet}'${inches}"` + } + + return `${Number.parseFloat(value.toFixed(2))}m` +} + +function getDraftAngleLabels( + start: FencePlanPoint, + end: FencePlanPoint, + segments: SegmentLike[], +): DraftAngleLabel[] { + const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]] + const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]] + const endpoints = [ + { id: 'start', point: start, draftVector: draftFromStart }, + { id: 'end', point: end, draftVector: draftFromEnd }, + ] + const labels: DraftAngleLabel[] = [] + + for (const endpoint of endpoints) { + const connectedSegment = segments.find((segment) => + Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)), + ) + if (!connectedSegment) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) + if (angle === null) continue + + labels.push({ + id: endpoint.id, + label: formatAngleRadians(angle), + position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], + }) + } + + return labels +} + +function getDraftMeasurementState( + start: FencePlanPoint, + end: FencePlanPoint, + segments: SegmentLike[], + unit: 'metric' | 'imperial', +): DraftMeasurementState { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + + if (length < 0.01) return null + + return { + lengthLabel: formatMeasurement(length, unit), + lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], + angleLabels: getDraftAngleLabels(start, end, segments), + } +} + +function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { + return [ + ...walls.map((wall) => ({ + id: wall.id, + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + })), + ...fences.map((fence) => ({ + id: fence.id, + start: fence.start, + end: fence.end, + curveOffset: fence.curveOffset, + })), + ] +} const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => { const direction = new Vector3(end.x - start.x, 0, end.z - start.z) @@ -70,12 +180,14 @@ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } = } export const FenceTool: React.FC = () => { + const unit = useViewer((state) => state.unit) const cursorRef = useRef(null) const previewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const shiftPressed = useRef(false) + const [draftMeasurement, setDraftMeasurement] = useState(null) useEffect(() => { let previousFenceEnd: [number, number] | null = null @@ -107,9 +219,18 @@ export const FenceTool: React.FC = () => { previousFenceEnd = currentFenceEnd updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current) + setDraftMeasurement( + getDraftMeasurementState( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + getReferenceSegments(walls, fences), + unit, + ), + ) } else { const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences }) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) + setDraftMeasurement(null) } } @@ -123,6 +244,7 @@ export const FenceTool: React.FC = () => { endingPoint.current.copy(startingPoint.current) buildingState.current = 1 previewRef.current.visible = true + setDraftMeasurement(null) } else { const snappedEnd = snapFenceDraftPoint({ point: localClick, @@ -137,6 +259,7 @@ export const FenceTool: React.FC = () => { createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) previewRef.current.visible = false buildingState.current = 0 + setDraftMeasurement(null) } } @@ -153,6 +276,7 @@ export const FenceTool: React.FC = () => { markToolCancelConsumed() buildingState.current = 0 previewRef.current.visible = false + setDraftMeasurement(null) } } @@ -169,7 +293,7 @@ export const FenceTool: React.FC = () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, []) + }, [unit]) return ( @@ -185,6 +309,38 @@ export const FenceTool: React.FC = () => { transparent /> + + {draftMeasurement && ( + <> + + {draftMeasurement.angleLabels.map((angleLabel) => ( + + ))} + + )} ) } + +function DraftMeasurementLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx index 8423ec4fb..8ebb774e4 100644 --- a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx +++ b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx @@ -2,32 +2,113 @@ import { type AnyNodeId, - type FenceNode, - type WallNode, emitter, + type FenceNode, type GridEvent, pauseSceneHistory, resumeSceneHistory, useScene, + type WallNode, } from '@pascal-app/core' -import { Html } from '@react-three/drei' import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' -import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting' +import { + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from '../shared/segment-angle' import { isWallLongEnough } from '../wall/wall-drafting' +import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting' function samePoint(a: FencePlanPoint, b: FencePlanPoint) { return a[0] === b[0] && a[1] === b[1] } +type SegmentLike = { + id: string + start: FencePlanPoint + end: FencePlanPoint + curveOffset?: number +} + +type AngleLabelState = { + label: string + position: [number, number, number] +} | null + +function getEndpointAngleLabel(args: { + preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number } + segments: SegmentLike[] + nodeId: FenceNode['id'] +}): AngleLabelState { + const { preview, segments, nodeId } = args + const endpoints = [ + { + point: preview.start, + }, + { + point: preview.end, + }, + ] + const targetSegment: SegmentLike = { + id: nodeId, + start: preview.start, + end: preview.end, + curveOffset: preview.curveOffset, + } + + for (const endpoint of endpoints) { + const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment) + if (!targetReference) continue + + const connectedSegment = segments.find( + (segment) => + segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)), + ) + if (!connectedSegment) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(targetReference.vector, connectedReference) + if (angle === null) continue + + return { + label: formatAngleRadians(angle), + position: [endpoint.point[0], 0.34, endpoint.point[1]], + } + } + + return null +} + +function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { + return [ + ...walls.map((wall) => ({ + id: wall.id, + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + })), + ...fences.map((fence) => ({ + id: fence.id, + start: fence.start, + end: fence.end, + curveOffset: fence.curveOffset, + })), + ] +} + type LinkedFenceSnapshot = { id: FenceNode['id'] start: FencePlanPoint end: FencePlanPoint + curveOffset?: number } function getLinkedFenceSnapshots(args: { @@ -62,6 +143,7 @@ function getLinkedFenceSnapshots(args: { id: node.id, start: [...node.start] as FencePlanPoint, end: [...node.end] as FencePlanPoint, + curveOffset: node.curveOffset, }) } @@ -77,6 +159,7 @@ function getLinkedFenceUpdates( ) { return linkedFences.map((fence) => ({ id: fence.id, + curveOffset: fence.curveOffset, start: samePoint(fence.start, originalStart) ? nextStart : samePoint(fence.start, originalEnd) @@ -112,6 +195,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = }), ) const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null) + const [angleLabel, setAngleLabel] = useState(null) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { const point = target.endpoint === 'start' ? target.fence.start : target.fence.end @@ -158,27 +242,35 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => { const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint + const linkedUpdates = detachLinkedFences + ? [] + : getLinkedFenceUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) previewRef.current = { start: nextStart, end: nextEnd } setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) - applyNodePreview([ - { id: nodeId, start: nextStart, end: nextEnd }, - ...(detachLinkedFences - ? [] - : getLinkedFenceUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - )), - ]) + setAngleLabel( + getEndpointAngleLabel({ + preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset }, + segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates], + nodeId, + }), + ) + applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates]) } - const restoreOriginal = () => { + const restoreOriginal = (clearAngleLabel = true) => { applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, ]) + if (clearAngleLabel) { + setAngleLabel(null) + } } const onGridMove = (event: GridEvent) => { @@ -240,6 +332,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = } useViewer.getState().setSelection({ selectedIds: [nodeId] }) + setAngleLabel(null) exitMoveMode() event.nativeEvent?.stopPropagation?.() } @@ -248,6 +341,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) resumeSceneHistory(useScene) + setAngleLabel(null) markToolCancelConsumed() exitMoveMode() } @@ -290,7 +384,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = return () => { if (!wasCommitted) { - restoreOriginal() + restoreOriginal(false) } resumeSceneHistory(useScene) emitter.off('grid:move', onGridMove) @@ -322,6 +416,23 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = + {angleLabel && }
) } + +function EndpointAngleLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index b6c1397aa..8556d4e75 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/shared/segment-angle.ts b/packages/editor/src/components/tools/shared/segment-angle.ts new file mode 100644 index 000000000..edc3b832d --- /dev/null +++ b/packages/editor/src/components/tools/shared/segment-angle.ts @@ -0,0 +1,156 @@ +import { + type FenceNode, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + type WallNode, +} from '@pascal-app/core' + +export type PlanPoint = [number, number] + +export type SegmentAngleLike = Pick + +export type SegmentAngleReference = { + vector: PlanPoint + orientation: 'directed' | 'axis' +} + +const POINT_MATCH_TOLERANCE = 1e-5 +const SEGMENT_POINT_TOLERANCE = 0.15 +const CURVE_TANGENT_SAMPLE_SPACING = 0.08 + +function distanceSquared(a: PlanPoint, b: PlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + + return dx * dx + dz * dz +} + +function pointsMatch(a: PlanPoint, b: PlanPoint, tolerance = POINT_MATCH_TOLERANCE) { + return distanceSquared(a, b) <= tolerance * tolerance +} + +function getProjectedPointOnSegment(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null { + const [x1, z1] = segment.start + const [x2, z2] = segment.end + const dx = x2 - x1 + const dz = z2 - z1 + const lengthSquared = dx * dx + dz * dz + + if (lengthSquared < 1e-9) { + return null + } + + const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared + if (t <= 0 || t >= 1) { + return null + } + + return [x1 + dx * t, z1 + dz * t] +} + +function getCurveTangentAtPoint(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null { + const curveLength = getWallCurveLength(segment) + const sampleCount = Math.max(24, Math.ceil(curveLength / CURVE_TANGENT_SAMPLE_SPACING)) + let best: { distance: number; tangent: PlanPoint } | null = null + + for (let index = 0; index <= sampleCount; index += 1) { + const frame = getWallCurveFrameAt(segment, index / sampleCount) + const candidate: PlanPoint = [frame.point.x, frame.point.y] + const distance = distanceSquared(point, candidate) + + if (best && distance >= best.distance) { + continue + } + + best = { + distance, + tangent: [frame.tangent.x, frame.tangent.y], + } + } + + if (!best || best.distance > SEGMENT_POINT_TOLERANCE * SEGMENT_POINT_TOLERANCE) { + return null + } + + return best.tangent +} + +export function formatAngleRadians(angle: number) { + return `${Math.round((angle * 180) / Math.PI)}°` +} + +export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): number | null { + const firstLength = Math.hypot(first[0], first[1]) + const secondLength = Math.hypot(second[0], second[1]) + + if (firstLength < 1e-6 || secondLength < 1e-6) return null + + const dot = first[0] * second[0] + first[1] * second[1] + const cosine = Math.min(1, Math.max(-1, dot / (firstLength * secondLength))) + + return Math.acos(cosine) +} + +export function getAngleToSegmentReference( + vector: PlanPoint, + reference: SegmentAngleReference, +): number | null { + const angle = getAngleBetweenVectors(vector, reference.vector) + + if (angle === null || reference.orientation === 'directed') { + return angle + } + + const reverseAngle = getAngleBetweenVectors(vector, [-reference.vector[0], -reference.vector[1]]) + + if (reverseAngle === null) { + return angle + } + + return Math.min(angle, reverseAngle) +} + +export function getSegmentAngleReferenceAtPoint( + point: PlanPoint, + segment: SegmentAngleLike, +): SegmentAngleReference | null { + if (pointsMatch(point, segment.start)) { + const frame = getWallCurveFrameAt(segment, 0) + + return { + vector: [frame.tangent.x, frame.tangent.y], + orientation: 'directed', + } + } + + if (pointsMatch(point, segment.end)) { + const frame = getWallCurveFrameAt(segment, 1) + + return { + vector: [-frame.tangent.x, -frame.tangent.y], + orientation: 'directed', + } + } + + if (isCurvedWall(segment)) { + const tangent = getCurveTangentAtPoint(point, segment) + + return tangent + ? { + vector: tangent, + orientation: 'axis', + } + : null + } + + const projected = getProjectedPointOnSegment(point, segment) + if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) { + return null + } + + return { + vector: [segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]], + orientation: 'axis', + } +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 0938a254e..777c8d724 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' @@ -126,7 +127,7 @@ export const ToolManager: React.FC = () => { const showBuildTool = mode === 'build' && tool !== null const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null - const handleSpawnSelected = (nodeId: `spawn_${string}`) => { + const handlePlacedNodeSelected = (nodeId: AnyNodeId) => { setSelection({ selectedIds: [nodeId] }) } @@ -134,7 +135,7 @@ export const ToolManager: React.FC = () => { <> {showSiteBoundaryEditor && } {/* World-space tools: site boundary and building movement operate in world coordinates */} - {movingNode?.type === 'building' && } + {movingNode?.type === 'building' && } {/* Building-local group: all other tools are relative to the selected building. Cursor visuals set positions in building-local space; this group applies the @@ -159,12 +160,15 @@ export const ToolManager: React.FC = () => { {curvingWall && } {curvingFence && } {movingNode && movingNode.type !== 'building' && ( - + )} {!movingNode && showBuildTool && tool === 'spawn' && ( - + )} - {!movingNode && BuildToolComponent && } + {!movingNode && showBuildTool && tool === 'column' && ( + + )} + {!movingNode && BuildToolComponent && tool !== 'column' && } ) diff --git a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx index 031f433e6..281141d0e 100644 --- a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx @@ -9,27 +9,87 @@ import { useScene, type WallNode, } from '@pascal-app/core' -import { Html } from '@react-three/drei' import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' import { - isWallLongEnough, - snapWallDraftPoint, - type WallPlanPoint, -} from './wall-drafting' + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from '../shared/segment-angle' +import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting' function samePoint(a: WallPlanPoint, b: WallPlanPoint) { return a[0] === b[0] && a[1] === b[1] } +type WallSegmentLike = { + id: WallNode['id'] + start: WallPlanPoint + end: WallPlanPoint + curveOffset?: number +} + +type AngleLabelState = { + label: string + position: [number, number, number] +} | null + +function getEndpointAngleLabel(args: { + preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number } + walls: WallSegmentLike[] + nodeId: WallNode['id'] +}): AngleLabelState { + const { preview, walls, nodeId } = args + const endpoints = [ + { + point: preview.start, + }, + { + point: preview.end, + }, + ] + const targetSegment: WallSegmentLike = { + id: nodeId, + start: preview.start, + end: preview.end, + curveOffset: preview.curveOffset, + } + + for (const endpoint of endpoints) { + const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment) + if (!targetReference) continue + + const connectedWall = walls.find( + (wall) => + wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), + ) + if (!connectedWall) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(targetReference.vector, connectedReference) + if (angle === null) continue + + return { + label: formatAngleRadians(angle), + position: [endpoint.point[0], 0.34, endpoint.point[1]], + } + } + + return null +} + type LinkedWallSnapshot = { id: WallNode['id'] start: WallPlanPoint end: WallPlanPoint + curveOffset?: number } function getLinkedWallSnapshots(args: { @@ -64,6 +124,7 @@ function getLinkedWallSnapshots(args: { id: node.id, start: [...node.start] as WallPlanPoint, end: [...node.end] as WallPlanPoint, + curveOffset: node.curveOffset, }) } @@ -79,6 +140,7 @@ function getLinkedWallUpdates( ) { return linkedWalls.map((wall) => ({ id: wall.id, + curveOffset: wall.curveOffset, start: samePoint(wall.start, originalStart) ? nextStart : samePoint(wall.start, originalEnd) @@ -114,6 +176,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ }), ) const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null) + const [angleLabel, setAngleLabel] = useState(null) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { const point = target.endpoint === 'start' ? target.wall.start : target.wall.end @@ -155,24 +218,43 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => { const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint + const linkedUpdates = detachLinkedWalls + ? [] + : getLinkedWallUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) previewRef.current = { start: nextStart, end: nextEnd } setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) - applyNodePreview([ - { id: nodeId, start: nextStart, end: nextEnd }, - ...(detachLinkedWalls - ? [] - : getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - )), - ]) + setAngleLabel( + getEndpointAngleLabel({ + preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset }, + walls: [ + ...levelWalls.map((wall) => ({ + id: wall.id, + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + })), + ...linkedUpdates, + ], + nodeId, + }), + ) + applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates]) } - const restoreOriginal = () => { - applyNodePreview([{ id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current]) + const restoreOriginal = (clearAngleLabel = true) => { + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + if (clearAngleLabel) { + setAngleLabel(null) + } } const onGridMove = (event: GridEvent) => { @@ -235,6 +317,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } useViewer.getState().setSelection({ selectedIds: [nodeId] }) + setAngleLabel(null) exitMoveMode() event.nativeEvent?.stopPropagation?.() } @@ -243,6 +326,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) resumeSceneHistory(useScene) + setAngleLabel(null) markToolCancelConsumed() exitMoveMode() } @@ -285,7 +369,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ return () => { if (!wasCommitted) { - restoreOriginal() + restoreOriginal(false) } resumeSceneHistory(useScene) emitter.off('grid:move', onGridMove) @@ -317,6 +401,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ + {angleLabel && } ) } + +function EndpointAngleLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index b6e18c1a0..9eee0d6fb 100755 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -3,7 +3,10 @@ import { type AnyNodeId, type DoorNode, getScaledDimensions, + getWallCurveFrameAt, + getWallCurveLength, type ItemNode, + isCurvedWall, useScene, type WallNode, WallNode as WallSchema, @@ -62,10 +65,10 @@ export function snapPointTo45Degrees( const snappedAngle = Math.round(angle / angleStep) * angleStep const distance = Math.sqrt(dx * dx + dz * dz) - return snapPointToGrid([ - start[0] + Math.cos(snappedAngle) * distance, - start[1] + Math.sin(snappedAngle) * distance, - ], step) + return snapPointToGrid( + [start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance], + step, + ) } export function getWallAngleSnapStep(step = getWallGridStep()): number { @@ -336,11 +339,17 @@ export function findWallSnapTarget( continue } - const candidates: Array = [ - wall.start, - wall.end, - projectPointOntoWall(point, wall), - ] + const candidates: Array = [wall.start, wall.end] + + if (isCurvedWall(wall)) { + const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) + for (let index = 0; index <= sampleCount; index += 1) { + const frame = getWallCurveFrameAt(wall, index / sampleCount) + candidates.push([frame.point.x, frame.point.y]) + } + } else { + candidates.push(projectPointOntoWall(point, wall)) + } for (const candidate of candidates) { if (!candidate) { continue diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index debf4e6e8..c43607a73 100755 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -1,14 +1,100 @@ import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' +import { + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from '../shared/segment-angle' import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting' const WALL_HEIGHT = 2.5 +const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22 +const DRAFT_ANGLE_LABEL_Y = 0.28 + +type DraftAngleLabel = { + id: string + label: string + position: [number, number, number] +} + +type DraftMeasurementState = { + lengthLabel: string + lengthPosition: [number, number, number] + angleLabels: DraftAngleLabel[] +} | null + +function formatMeasurement(value: number, unit: 'metric' | 'imperial') { + if (unit === 'imperial') { + const feet = value * 3.280_84 + const wholeFeet = Math.floor(feet) + const inches = Math.round((feet - wholeFeet) * 12) + if (inches === 12) return `${wholeFeet + 1}'0"` + return `${wholeFeet}'${inches}"` + } + + return `${Number.parseFloat(value.toFixed(2))}m` +} + +function getDraftAngleLabels( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): DraftAngleLabel[] { + const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]] + const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]] + const endpoints = [ + { id: 'start', point: start, draftVector: draftFromStart }, + { id: 'end', point: end, draftVector: draftFromEnd }, + ] + const labels: DraftAngleLabel[] = [] + + for (const endpoint of endpoints) { + const connectedWall = walls.find((wall) => + Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), + ) + if (!connectedWall) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) + if (angle === null) continue + + labels.push({ + id: endpoint.id, + label: formatAngleRadians(angle), + position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], + }) + } + + return labels +} + +function getDraftMeasurementState( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], + unit: 'metric' | 'imperial', +): DraftMeasurementState { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + + if (length < 0.01) return null + + return { + lengthLabel: formatMeasurement(length, unit), + lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], + angleLabels: getDraftAngleLabels(start, end, walls), + } +} /** * Update wall preview mesh geometry to create a vertical plane between two points @@ -67,12 +153,14 @@ const getCurrentLevelWalls = (): WallNode[] => { } export const WallTool: React.FC = () => { + const unit = useViewer((state) => state.unit) const cursorRef = useRef(null) const wallPreviewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const shiftPressed = useRef(false) + const [draftMeasurement, setDraftMeasurement] = useState(null) useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] @@ -109,9 +197,18 @@ export const WallTool: React.FC = () => { previousWallEnd = currentWallEnd updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + setDraftMeasurement( + getDraftMeasurementState( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + walls, + unit, + ), + ) } else { // Not drawing a wall yet, show the snapped anchor point. cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1]) + setDraftMeasurement(null) } } @@ -126,6 +223,7 @@ export const WallTool: React.FC = () => { endingPoint.current.copy(startingPoint.current) buildingState.current = 1 wallPreviewRef.current.visible = true + setDraftMeasurement(null) } else if (buildingState.current === 1) { const snappedEnd = snapWallDraftPoint({ point: localClick, @@ -140,6 +238,7 @@ export const WallTool: React.FC = () => { createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) wallPreviewRef.current.visible = false buildingState.current = 0 + setDraftMeasurement(null) } } @@ -160,6 +259,7 @@ export const WallTool: React.FC = () => { markToolCancelConsumed() buildingState.current = 0 wallPreviewRef.current.visible = false + setDraftMeasurement(null) } } @@ -176,7 +276,7 @@ export const WallTool: React.FC = () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, []) + }, [unit]) return ( @@ -195,6 +295,38 @@ export const WallTool: React.FC = () => { transparent /> + + {draftMeasurement && ( + <> + + {draftMeasurement.angleLabels.map((angleLabel) => ( + + ))} + + )} ) } + +function DraftMeasurementLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} 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 219378852..cc060af61 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 000000000..8b7e97de4 --- /dev/null +++ b/packages/editor/src/components/ui/panels/column-panel.tsx @@ -0,0 +1,715 @@ +'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, + shaftTwistStep: 0, + } +} + +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({ + shaftTwistStep: value, + ...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8 + ? { shaftSegmentCount: 12 } + : {}), + }) + } + precision={0} + step={5} + unit="°" + value={node.shaftTwistStep ?? 0} + /> + {Math.abs(node.shaftTwistStep ?? 0) > 0.001 && ( + handleUpdate({ shaftSegmentCount: Math.round(value) })} + precision={0} + step={1} + value={node.shaftSegmentCount ?? 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/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 363a859ce..fabd1c3ba 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -254,6 +254,7 @@ export function DoorPanel() { const normHeights = node.segments.map((seg) => seg.heightRatio / hSum) const isOpening = node.openingKind === 'opening' const openingShape = node.openingShape ?? 'rectangle' + const doorShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle' const openingRadiusMode = node.openingRadiusMode ?? 'all' const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15] const cornerRadius = node.cornerRadius ?? 0.15 @@ -380,6 +381,108 @@ export function DoorPanel() { /> + {!isOpening && ( + +
+ + handleUpdate({ + openingShape: v as DoorNode['openingShape'], + ...(v === 'rounded' + ? { + openingRadiusMode, + openingTopRadii, + cornerRadius: Math.min(cornerRadius, maxRoundedRadius), + openingRevealRadius, + } + : {}), + ...(v === 'arch' ? { archHeight } : {}), + }) + } + options={[ + { label: 'Rect', value: 'rectangle' }, + { label: 'Rounded', value: 'rounded' }, + { label: 'Arch', value: 'arch' }, + ]} + value={doorShape} + /> +
+ {doorShape === 'rounded' && ( + <> +
+ + handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] }) + } + options={[ + { label: 'All', value: 'all' }, + { label: 'Individual', value: 'individual' }, + ]} + value={openingRadiusMode} + /> +
+ {openingRadiusMode === 'all' ? ( + previewDoorUpdate('cornerRadius', v)} + onCommit={(v) => commitDoorPreview('cornerRadius', v)} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + ) : ( + <> + {[ + ['Top Left', 0], + ['Top Right', 1], + ].map(([label, index]) => ( + setOpeningTopRadius(index as number, v)} + onCommit={(v) => setOpeningTopRadius(index as number, v, true)} + precision={2} + step={0.05} + unit="m" + value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100} + /> + ))} + + )} + previewDoorUpdate('openingRevealRadius', v)} + onCommit={(v) => commitDoorPreview('openingRevealRadius', v)} + precision={3} + step={0.005} + unit="m" + value={Math.round(openingRevealRadius * 1000) / 1000} + /> + + )} + {doorShape === 'arch' && ( + handleUpdate({ archHeight: v })} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={Math.round(archHeight * 100) / 100} + /> + )} +
+ )} + {isOpening && (
@@ -468,6 +571,7 @@ export function DoorPanel() { min={0.05} onChange={(v) => handleUpdate({ archHeight: v })} precision={2} + restoreOnCommit={false} step={0.05} unit="m" value={Math.round(archHeight * 100) / 100} diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index 6cc26c666..3b5456801 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 d5faef6f0..e948ab808 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/panels/reference-panel.tsx b/packages/editor/src/components/ui/panels/reference-panel.tsx index 57b771c8d..723f76271 100644 --- a/packages/editor/src/components/ui/panels/reference-panel.tsx +++ b/packages/editor/src/components/ui/panels/reference-panel.tsx @@ -2,7 +2,6 @@ import { type AnyNode, - emitter, type GuideNode, loadAssetUrl, saveAsset, @@ -11,6 +10,7 @@ import { } from '@pascal-app/core' import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' +import { guideEmitter } from '../../../lib/guide-events' import { getGuideImageName } from '../../../lib/local-guide-image' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -98,7 +98,7 @@ export function ReferencePanel() { } deleteNode(selectedReferenceId as AnyNode['id']) - emitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] }) + guideEmitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] }) clearGuideUi(selectedReferenceId) setSelectedReferenceId(null) }, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId]) @@ -108,11 +108,11 @@ export function ReferencePanel() { return } - emitter.emit('guide:set-reference-scale', { guideId: node.id }) + guideEmitter.emit('guide:set-reference-scale', { guideId: node.id }) }, [node]) const handleCancelScale = useCallback(() => { - emitter.emit('guide:cancel-reference-scale') + guideEmitter.emit('guide:cancel-reference-scale') }, []) useEffect(() => { diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index 531620177..db80f6f9f 100755 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -64,7 +64,7 @@ function isSameRadiusTuple( current: [number, number, number, number], next: [number, number, number, number], ) { - return current.every((value, index) => Math.abs(value - next[index]) < 1e-6) + return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6) } export function WindowPanel() { @@ -267,6 +267,7 @@ export function WindowPanel() { const normRows = node.rowRatios.map((r) => r / rowSum) const isOpening = node.openingKind === 'opening' const openingShape = node.openingShape ?? 'rectangle' + const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle' const openingRadiusMode = node.openingRadiusMode ?? 'all' const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] const cornerRadius = node.cornerRadius ?? 0.15 @@ -457,6 +458,108 @@ export function WindowPanel() { /> + {!isOpening && ( + + + handleUpdate({ + openingShape: value as WindowNode['openingShape'], + ...(value === 'rounded' + ? { + openingRadiusMode, + openingCornerRadii, + cornerRadius: Math.min(cornerRadius, maxRoundedRadius), + openingRevealRadius, + } + : {}), + ...(value === 'arch' ? { archHeight } : {}), + }) + } + options={[ + { value: 'rectangle', label: 'Rect' }, + { value: 'rounded', label: 'Rounded' }, + { value: 'arch', label: 'Arch' }, + ]} + value={windowShape} + /> + {windowShape === 'rounded' && ( +
+ + handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) + } + options={[ + { value: 'all', label: 'All' }, + { value: 'individual', label: 'Individual' }, + ]} + value={openingRadiusMode} + /> + {openingRadiusMode === 'all' ? ( + previewWindowUpdate('cornerRadius', value)} + onCommit={(value) => commitWindowPreview('cornerRadius', value)} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + ) : ( + <> + {[ + ['Top Left', 0], + ['Top Right', 1], + ['Bottom Right', 2], + ['Bottom Left', 3], + ].map(([label, index]) => ( + setOpeningCornerRadius(index as number, value)} + onCommit={(value) => setOpeningCornerRadius(index as number, value, true)} + precision={2} + step={0.05} + unit="m" + value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100} + /> + ))} + + )} + previewWindowUpdate('openingRevealRadius', value)} + onCommit={(value) => commitWindowPreview('openingRevealRadius', value)} + precision={3} + step={0.005} + unit="m" + value={Math.round(openingRevealRadius * 1000) / 1000} + /> +
+ )} + {windowShape === 'arch' && ( +
+ handleUpdate({ archHeight: value })} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={Math.round(archHeight * 100) / 100} + /> +
+ )} +
+ )} + {isOpening && ( handleUpdate({ archHeight: value })} precision={2} + restoreOnCommit={false} step={0.05} unit="m" value={Math.round(archHeight * 100) / 100} 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 000000000..af37aaf16 --- /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 4a4d91942..2e7ba5df9 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/guide-events.ts b/packages/editor/src/lib/guide-events.ts new file mode 100644 index 000000000..40bd172f0 --- /dev/null +++ b/packages/editor/src/lib/guide-events.ts @@ -0,0 +1,10 @@ +import type { GuideNode } from '@pascal-app/core' +import mitt from 'mitt' + +type GuideEditorEvents = { + 'guide:set-reference-scale': { guideId: GuideNode['id'] } + 'guide:cancel-reference-scale': undefined + 'guide:deleted': { guideId: GuideNode['id'] } +} + +export const guideEmitter = mitt() diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index ffdde7860..37582b0d1 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 560dc0948..b3aa2616c 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 000000000..d80b89cf8 --- /dev/null +++ b/packages/viewer/src/components/renderers/column/column-renderer.tsx @@ -0,0 +1,1496 @@ +import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' +import { createContext, useContext, useMemo, useRef } from 'react' +import type { Group, Material } from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' +import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' +import { + createColumnBoxGeometry, + createColumnCylinderGeometry, + createColumnSphereGeometry, + createColumnTorusGeometry, +} from '../../../systems/column/column-geometry' + +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 + const hasTwist = Math.abs(node.shaftTwistStep ?? 0) > 0.001 + return Math.max( + hasTwist ? 4 : 1, + shaftProfile === 'straight' && shaftTaper <= 0 && !hasTwist + ? 1 + : (node.shaftSegmentCount ?? (hasTwist ? 12 : 24)), + ) +} + +function getShaftTwistRadians(node: ColumnNode, index: number) { + return ((node.shaftTwistStep ?? 0) * Math.PI * index) / 180 +} + +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) +} + +type VectorTuple = [number, number, number] + +function MappedBox({ + depth, + height, + position, + rotation, + softenEdges = true, + width, +}: { + depth: number + height: number + position: VectorTuple + rotation?: VectorTuple + softenEdges?: boolean + width: number +}) { + 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 geometry = useMemo(() => { + if (height <= 0 || width <= 0 || depth <= 0) return null + return createColumnBoxGeometry(width, height, depth, bevelRadius) + }, [bevelRadius, depth, height, width]) + + if (!geometry) return null + + return ( + + + + + ) +} + +function MappedCylinder({ + height, + position, + radius, + radiusBottom = radius, + radiusTop = radius, + radiusX = 1, + radiusZ = 1, + rotation, + segments = 32, +}: { + height: number + position: VectorTuple + radius: number + radiusBottom?: number + radiusTop?: number + radiusX?: number + radiusZ?: number + rotation?: VectorTuple + segments?: number +}) { + const geometry = useMemo(() => { + if (height <= 0 || radius <= 0 || radiusBottom < 0 || radiusTop < 0) return null + return createColumnCylinderGeometry({ + height, + radiusBottom, + radiusTop, + radiusX, + radiusZ, + segments, + }) + }, [height, radius, radiusBottom, radiusTop, radiusX, radiusZ, segments]) + + if (!geometry) return null + + return ( + + + + + ) +} + +function MappedCone({ + height, + position, + radiusX, + radiusZ = radiusX, + rotation, + segments = 6, +}: { + height: number + position: VectorTuple + radiusX: number + radiusZ?: number + rotation?: VectorTuple + segments?: number +}) { + const geometry = useMemo(() => { + if (height <= 0 || radiusX <= 0 || radiusZ <= 0) return null + return createColumnCylinderGeometry({ + height, + radiusBottom: 1, + radiusTop: 0, + radiusX, + radiusZ, + segments, + }) + }, [height, radiusX, radiusZ, segments]) + + if (!geometry) return null + + return ( + + + + + ) +} + +function MappedSphere({ + position, + radius, + segments = 10, + verticalSegments = 8, +}: { + position: VectorTuple + radius: number + segments?: number + verticalSegments?: number +}) { + const geometry = useMemo(() => { + if (radius <= 0) return null + return createColumnSphereGeometry(radius, segments, verticalSegments) + }, [radius, segments, verticalSegments]) + + if (!geometry) return null + + return ( + + + + + ) +} + +function MappedTorus({ + arc, + position, + ringRadius, + rotation, + scaleX, + scaleY, + scaleZ, + tubeRadius, +}: { + arc?: number + position: VectorTuple + ringRadius: number + rotation?: VectorTuple + scaleX?: number + scaleY?: number + scaleZ?: number + tubeRadius: number +}) { + const geometry = useMemo(() => { + if (ringRadius <= 0 || tubeRadius <= 0) return null + return createColumnTorusGeometry({ arc, ringRadius, scaleX, scaleY, scaleZ, tubeRadius }) + }, [arc, ringRadius, scaleX, scaleY, scaleZ, tubeRadius]) + + if (!geometry) return null + + return ( + + + + + ) +} + +function SquareBlock({ + y, + height, + width, + depth, + softenEdges = true, +}: { + y: number + height: number + width: number + depth: number + softenEdges?: boolean +}) { + return ( + + ) +} + +function RoundBlock({ + x = 0, + y, + z = 0, + height, + radius, + segments = 32, +}: { + x?: number + y: number + z?: number + height: number + radius: number + segments?: number +}) { + 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 +}) { + 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 80eb5b6ac..c4b6db979 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 f51c4dd54..9f7adadfd 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 d0b39b537..18487347e 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 } diff --git a/packages/viewer/src/systems/column/column-geometry.ts b/packages/viewer/src/systems/column/column-geometry.ts new file mode 100644 index 000000000..3319cfd65 --- /dev/null +++ b/packages/viewer/src/systems/column/column-geometry.ts @@ -0,0 +1,186 @@ +import { + BoxGeometry, + type BufferGeometry, + CylinderGeometry, + Float32BufferAttribute, + SphereGeometry, + TorusGeometry, +} from 'three' +import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' + +const COLUMN_UV_SCALE = 1 + +function setUvAttributes(geometry: BufferGeometry, uvs: number[]) { + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) + geometry.setAttribute('uv2', new Float32BufferAttribute(uvs.slice(), 2)) + return geometry +} + +function toUvReadyGeometry(geometry: BufferGeometry) { + return geometry.index ? geometry.toNonIndexed() : geometry +} + +function applyPlanarColumnUvs(geometry: BufferGeometry) { + const mappedGeometry = toUvReadyGeometry(geometry) + const positions = mappedGeometry.getAttribute('position') + const normals = mappedGeometry.getAttribute('normal') + const uvs: number[] = [] + + for (let index = 0; index < positions.count; index += 1) { + const x = positions.getX(index) + const y = positions.getY(index) + const z = positions.getZ(index) + const normalX = normals ? Math.abs(normals.getX(index)) : 0 + const normalY = normals ? Math.abs(normals.getY(index)) : 1 + const normalZ = normals ? Math.abs(normals.getZ(index)) : 0 + + if (normalY >= normalX && normalY >= normalZ) { + uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE) + } else if (normalX >= normalZ) { + uvs.push(z * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE) + } else { + uvs.push(x * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE) + } + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function ellipseCircumference(radiusX: number, radiusZ: number) { + const a = Math.max(0.001, Math.abs(radiusX)) + const b = Math.max(0.001, Math.abs(radiusZ)) + return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b))) +} + +function applyCylindricalColumnUvs( + geometry: BufferGeometry, + sideCircumference: number, + height: number, +) { + const mappedGeometry = toUvReadyGeometry(geometry) + const positions = mappedGeometry.getAttribute('position') + const normals = mappedGeometry.getAttribute('normal') + const defaultUvs = mappedGeometry.getAttribute('uv') + const halfHeight = height / 2 + const uvs: number[] = [] + + for (let index = 0; index < positions.count; index += 1) { + const x = positions.getX(index) + const y = positions.getY(index) + const z = positions.getZ(index) + const normalY = normals ? Math.abs(normals.getY(index)) : 0 + + if (normalY > 0.65) { + uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE) + } else { + const defaultU = defaultUvs ? defaultUvs.getX(index) : 0 + uvs.push(defaultU * sideCircumference * COLUMN_UV_SCALE, (y + halfHeight) * COLUMN_UV_SCALE) + } + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function applySphericalColumnUvs(geometry: BufferGeometry, radius: number) { + const mappedGeometry = toUvReadyGeometry(geometry) + const defaultUvs = mappedGeometry.getAttribute('uv') + if (!defaultUvs) return mappedGeometry + + const uvs: number[] = [] + const circumference = Math.PI * 2 * radius + const arcHeight = Math.PI * radius + + for (let index = 0; index < defaultUvs.count; index += 1) { + uvs.push( + defaultUvs.getX(index) * circumference * COLUMN_UV_SCALE, + defaultUvs.getY(index) * arcHeight * COLUMN_UV_SCALE, + ) + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function applyTorusColumnUvs(geometry: BufferGeometry, ringRadius: number, tubeRadius: number) { + const mappedGeometry = toUvReadyGeometry(geometry) + const defaultUvs = mappedGeometry.getAttribute('uv') + if (!defaultUvs) return mappedGeometry + + const uvs: number[] = [] + const ringLength = Math.PI * 2 * Math.max(0.001, ringRadius) + const tubeLength = Math.PI * 2 * Math.max(0.001, tubeRadius) + + for (let index = 0; index < defaultUvs.count; index += 1) { + uvs.push( + defaultUvs.getX(index) * ringLength * COLUMN_UV_SCALE, + defaultUvs.getY(index) * tubeLength * COLUMN_UV_SCALE, + ) + } + + return setUvAttributes(mappedGeometry, uvs) +} + +export function createColumnBoxGeometry( + width: number, + height: number, + depth: number, + bevelRadius = 0, +) { + const geometry = + bevelRadius > 0.001 + ? new RoundedBoxGeometry(width, height, depth, 3, bevelRadius) + : new BoxGeometry(width, height, depth) + return applyPlanarColumnUvs(geometry) +} + +export function createColumnCylinderGeometry({ + height, + radiusBottom, + radiusTop = radiusBottom, + radiusX = 1, + radiusZ = 1, + segments = 32, +}: { + height: number + radiusBottom: number + radiusTop?: number + radiusX?: number + radiusZ?: number + segments?: number +}) { + const geometry = new CylinderGeometry(radiusTop, radiusBottom, height, segments) + geometry.scale(radiusX, 1, radiusZ) + const sideRadius = Math.max(radiusTop, radiusBottom) + return applyCylindricalColumnUvs( + geometry, + ellipseCircumference(sideRadius * radiusX, sideRadius * radiusZ), + height, + ) +} + +export function createColumnSphereGeometry(radius: number, widthSegments = 10, heightSegments = 8) { + return applySphericalColumnUvs(new SphereGeometry(radius, widthSegments, heightSegments), radius) +} + +export function createColumnTorusGeometry({ + arc = Math.PI * 2, + radialSegments = 10, + ringRadius, + scaleX = ringRadius, + scaleY = ringRadius, + scaleZ = 1, + tubeRadius, + tubularSegments = 24, +}: { + arc?: number + radialSegments?: number + ringRadius: number + scaleX?: number + scaleY?: number + scaleZ?: number + tubeRadius: number + tubularSegments?: number +}) { + const geometry = new TorusGeometry(1, 0.18, radialSegments, tubularSegments, arc) + geometry.scale(scaleX, scaleY, scaleZ) + return applyTorusColumnUvs(geometry, ringRadius, tubeRadius) +} diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index a782ed954..6e87ca9ee 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1,10 +1,5 @@ +import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' -import { - type AnyNodeId, - type DoorNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' import * as THREE from 'three' import { baseMaterial, glassMaterial } from '../../lib/materials' @@ -55,6 +50,300 @@ function addBox( parent.add(m) } +function addShape( + parent: THREE.Object3D, + material: THREE.Material, + shape: THREE.Shape, + depth: number, +) { + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: false, + curveSegments: 24, + }) + geometry.translate(0, 0, -depth / 2) + const mesh = new THREE.Mesh(geometry, material) + parent.add(mesh) +} + +function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) { + return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01)) +} + +function createArchShape( + left: number, + right: number, + bottom: number, + top: number, + archHeight: number, +) { + const centerX = (left + right) / 2 + const halfWidth = (right - left) / 2 + const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight) + const springY = top - clampedArchHeight + const shape = new THREE.Shape() + const segments = 32 + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, springY) + for (let index = 1; index <= segments; index += 1) { + const x = right + (left - right) * (index / segments) + shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight)) + } + shape.lineTo(left, bottom) + shape.closePath() + return shape +} + +function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) { + if (halfWidth <= 1e-6) return springY + const t = Math.min(Math.abs(x) / halfWidth, 1) + return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0)) +} + +function createArchBandShape( + width: number, + outerSpringY: number, + outerTopY: number, + innerSpringY: number, + innerTopY: number, + insetX: number, +) { + const halfWidth = width / 2 + const innerHalfWidth = Math.max(halfWidth - insetX, 0) + const outerArchHeight = Math.max(outerTopY - outerSpringY, 0) + const safeInnerTopY = Math.min(innerTopY, outerTopY - 0.001) + const safeInnerSpringY = Math.min(innerSpringY, safeInnerTopY - 0.001) + const innerArchHeight = Math.max(safeInnerTopY - safeInnerSpringY, 0) + const shape = new THREE.Shape() + const segments = 32 + const getSafeInnerBoundaryY = (x: number) => + Math.min( + getArchBoundaryY(x, innerHalfWidth, safeInnerSpringY, innerArchHeight), + getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight) - 0.001, + ) + + shape.moveTo(-halfWidth, outerSpringY) + for (let index = 1; index <= segments; index += 1) { + const x = -halfWidth + width * (index / segments) + shape.lineTo(x, getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight)) + } + + if (innerHalfWidth <= 0.001 || safeInnerTopY <= safeInnerSpringY + 0.001) { + shape.lineTo(halfWidth, outerSpringY) + shape.closePath() + return shape + } + + shape.lineTo(innerHalfWidth, outerSpringY) + shape.lineTo(innerHalfWidth, getSafeInnerBoundaryY(innerHalfWidth)) + for (let index = segments - 1; index >= 0; index -= 1) { + const x = -innerHalfWidth + innerHalfWidth * 2 * (index / segments) + shape.lineTo(x, getSafeInnerBoundaryY(x)) + } + shape.lineTo(-innerHalfWidth, outerSpringY) + shape.lineTo(-halfWidth, outerSpringY) + shape.closePath() + + return shape +} + +function createArchHeadBarShape(width: number, bottomY: number, springY: number, topY: number) { + const halfWidth = width / 2 + const archHeight = Math.max(topY - springY, 0) + const shape = new THREE.Shape() + const segments = 32 + + shape.moveTo(-halfWidth, bottomY) + shape.lineTo(halfWidth, bottomY) + shape.lineTo(halfWidth, springY) + for (let index = 1; index <= segments; index += 1) { + const x = halfWidth - width * (index / segments) + shape.lineTo(x, getArchBoundaryY(x, halfWidth, springY, archHeight)) + } + shape.lineTo(-halfWidth, bottomY) + shape.closePath() + + return shape +} + +type TopCornerRadii = { + topLeft: number + topRight: number +} + +function normalizeTopCornerRadii( + radii: TopCornerRadii, + width: number, + height: number, +): TopCornerRadii { + const next = { ...radii } + const scale = Math.min( + 1, + width / Math.max(next.topLeft + next.topRight, 1e-6), + height / Math.max(next.topLeft, 1e-6), + height / Math.max(next.topRight, 1e-6), + ) + + if (scale < 1) { + next.topLeft *= scale + next.topRight *= scale + } + + return next +} + +function getDoorTopRadii(node: DoorNode, width: number, height: number): TopCornerRadii { + if (node.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0] = node.openingTopRadii ?? [0.15, 0.15] + return normalizeTopCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height) + const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius } +} + +function createRoundedTopShape( + left: number, + right: number, + bottom: number, + top: number, + radii: TopCornerRadii, +) { + const shape = new THREE.Shape() + const { topLeft, topRight } = normalizeTopCornerRadii(radii, right - left, top - bottom) + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, top - topRight) + if (topRight > 1e-6) { + shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false) + } else { + shape.lineTo(right, top) + } + + shape.lineTo(left + topLeft, top) + if (topLeft > 1e-6) { + shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false) + } else { + shape.lineTo(left, top) + } + + shape.lineTo(left, bottom) + shape.closePath() + return shape +} + +function createRoundedDoorFrameShape( + width: number, + height: number, + frameThickness: number, + radii: TopCornerRadii, +) { + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const outerRadii = normalizeTopCornerRadii(radii, width, height) + const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii) + const inset = Math.min(frameThickness, width / 2 - 0.005, height - 0.005) + + if (inset <= 0.001) return outer + + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerTop = top - inset + const innerRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(outerRadii.topLeft - inset, 0), + topRight: Math.max(outerRadii.topRight - inset, 0), + }, + innerRight - innerLeft, + innerTop - bottom, + ) + const holeShape = createRoundedTopShape(innerLeft, innerRight, bottom, innerTop, innerRadii) + const hole = new THREE.Path(holeShape.getPoints(32).reverse()) + outer.holes.push(hole) + + return outer +} + +function shapeToReversedPath(shape: THREE.Shape) { + return new THREE.Path(shape.getPoints(40).reverse()) +} + +function createRoundedLeafFrameShape( + width: number, + bottom: number, + top: number, + radii: TopCornerRadii, + insetX: number, + insetY: number, +) { + const halfWidth = width / 2 + const outerRadii = normalizeTopCornerRadii(radii, width, top - bottom) + const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii) + const innerLeft = -halfWidth + insetX + const innerRight = halfWidth - insetX + const innerBottom = bottom + insetY + const innerTop = top - insetY + + if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer + + const innerRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(outerRadii.topLeft - Math.max(insetX, insetY), 0), + topRight: Math.max(outerRadii.topRight - Math.max(insetX, insetY), 0), + }, + innerRight - innerLeft, + innerTop - innerBottom, + ) + outer.holes.push( + shapeToReversedPath( + createRoundedTopShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii), + ), + ) + + return outer +} + +function createTopClippedRectShape( + left: number, + right: number, + bottom: number, + top: number, + getBoundaryY: (x: number) => number, +) { + const segments = 20 + const points: { x: number; y: number }[] = [] + + for (let index = 0; index <= segments; index += 1) { + const t = index / segments + const x = right + (left - right) * t + const y = Math.min(top, getBoundaryY(x)) + if (y > bottom + 0.001) points.push({ x, y }) + } + + if (points.length < 2) return null + + const shape = new THREE.Shape() + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + for (const point of points) { + shape.lineTo(point.x, point.y) + } + shape.closePath() + return shape +} + function disposeObject(object: THREE.Object3D) { object.traverse((child) => { if (child instanceof THREE.Mesh) child.geometry.dispose() @@ -82,6 +371,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { width, height, openingKind, + openingShape, frameThickness, frameDepth, threshold, @@ -129,41 +419,111 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { y: number, z: number, ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) + const addLeafShape = (shape: THREE.Shape, material: THREE.Material, depth: number, z = 0) => { + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: false, + curveSegments: 24, + }) + geometry.translate(-hingeX, 0, -depth / 2 + z) + const leafMesh = new THREE.Mesh(geometry, material) + leafGroup.add(leafMesh) + } // ── Frame members ── - // Left post — full height - addBox( - mesh, - baseMaterial, - frameThickness, - height, - frameDepth, - -width / 2 + frameThickness / 2, - 0, - 0, - ) - // Right post — full height - addBox( - mesh, - baseMaterial, - frameThickness, - height, - frameDepth, - width / 2 - frameThickness / 2, - 0, - 0, - ) - // Head (top bar) — full width - addBox( - mesh, - baseMaterial, - width, - frameThickness, - frameDepth, - 0, - height / 2 - frameThickness / 2, - 0, - ) + if (openingShape === 'arch') { + const frameBottom = -height / 2 + const frameTop = height / 2 + const frameArchHeight = getClampedArchHeight(width, height, node.archHeight) + const frameSpringY = frameTop - frameArchHeight + const frameInnerTopY = frameTop - frameThickness + const frameInnerSpringY = Math.min(frameSpringY + frameThickness, frameInnerTopY) + const useShallowHeadBar = frameArchHeight <= frameThickness * 2 + const frameHeadBottomY = useShallowHeadBar ? frameSpringY - frameThickness : frameSpringY + const postHeight = Math.max(frameHeadBottomY - frameBottom, 0.01) + + addBox( + mesh, + baseMaterial, + frameThickness, + postHeight, + frameDepth, + -width / 2 + frameThickness / 2, + frameBottom + postHeight / 2, + 0, + ) + addBox( + mesh, + baseMaterial, + frameThickness, + postHeight, + frameDepth, + width / 2 - frameThickness / 2, + frameBottom + postHeight / 2, + 0, + ) + addShape( + mesh, + baseMaterial, + useShallowHeadBar + ? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop) + : createArchBandShape( + width, + frameSpringY, + frameTop, + frameInnerSpringY, + frameInnerTopY, + frameThickness, + ), + frameDepth, + ) + } else if (openingShape === 'rounded') { + addShape( + mesh, + baseMaterial, + createRoundedDoorFrameShape( + width, + height, + frameThickness, + getDoorTopRadii(node, width, height), + ), + frameDepth, + ) + } else { + // Left post — full height + addBox( + mesh, + baseMaterial, + frameThickness, + height, + frameDepth, + -width / 2 + frameThickness / 2, + 0, + 0, + ) + // Right post — full height + addBox( + mesh, + baseMaterial, + frameThickness, + height, + frameDepth, + width / 2 - frameThickness / 2, + 0, + 0, + ) + // Head (top bar) — full width + addBox( + mesh, + baseMaterial, + width, + frameThickness, + frameDepth, + 0, + height / 2 - frameThickness / 2, + 0, + ) + } // ── Threshold (inside the frame) ── if (threshold) { @@ -179,16 +539,139 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { ) } - // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ── + const usesShapedLeaf = openingShape === 'arch' || openingShape === 'rounded' + const leafBottom = leafCenterY - leafH / 2 + const leafTop = leafCenterY + leafH / 2 + const leafArchHeight = getClampedArchHeight( + leafW, + leafH, + Math.max((node.archHeight ?? leafW / 2) - frameThickness, 0.01), + ) + const leafArchSpringY = leafTop - leafArchHeight + const frameRadii = getDoorTopRadii(node, width, height) + const leafTopRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(frameRadii.topLeft - frameThickness, 0), + topRight: Math.max(frameRadii.topRight - frameThickness, 0), + }, + leafW, + leafH, + ) const cpX = contentPadding[0] const cpY = contentPadding[1] - if (hasLeafContent && cpY > 0) { + const useShallowLeafHeadBar = openingShape === 'arch' && cpY > 0 && leafArchHeight <= cpY * 2 + const shallowLeafHeadBottomY = leafArchSpringY - cpY + const getLeafBoundaryY = (x: number) => { + if (openingShape === 'arch') { + if (useShallowLeafHeadBar) return shallowLeafHeadBottomY + + const innerTop = leafTop - cpY + const innerSpringY = Math.min(Math.max(leafArchSpringY + cpY, leafBottom + cpY), innerTop) + const innerArchHeight = Math.max(innerTop - innerSpringY, 0.001) + const halfContentW = Math.max((leafW - 2 * cpX) / 2, 0.001) + const outerBoundaryY = getArchBoundaryY(x, leafW / 2, leafArchSpringY, leafArchHeight) + return Math.min( + getArchBoundaryY(x, halfContentW, innerSpringY, innerArchHeight), + outerBoundaryY - 0.001, + ) + } + + if (openingShape === 'rounded') { + const left = -leafW / 2 + cpX + const right = leafW / 2 - cpX + const top = leafTop - cpY + const innerRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(leafTopRadii.topLeft - Math.max(cpX, cpY), 0), + topRight: Math.max(leafTopRadii.topRight - Math.max(cpX, cpY), 0), + }, + right - left, + top - (leafBottom + cpY), + ) + + if (innerRadii.topLeft > 1e-6 && x < left + innerRadii.topLeft) { + const centerX = left + innerRadii.topLeft + const centerY = top - innerRadii.topLeft + const dx = x - centerX + return centerY + Math.sqrt(Math.max(innerRadii.topLeft * innerRadii.topLeft - dx * dx, 0)) + } + + if (innerRadii.topRight > 1e-6 && x > right - innerRadii.topRight) { + const centerX = right - innerRadii.topRight + const centerY = top - innerRadii.topRight + const dx = x - centerX + return centerY + Math.sqrt(Math.max(innerRadii.topRight * innerRadii.topRight - dx * dx, 0)) + } + + return top + } + + return leafTop + } + const createLeafCellShape = (left: number, right: number, bottom: number, top: number) => + createTopClippedRectShape(left, right, bottom, top, getLeafBoundaryY) + + // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ── + if (hasLeafContent && openingShape === 'arch') { + const leafInnerTopY = leafTop - cpY + const leafInnerSpringY = Math.min( + Math.max(leafArchSpringY + cpY, leafBottom + cpY), + leafInnerTopY, + ) + const sideBottom = leafBottom + cpY + const sideTop = useShallowLeafHeadBar ? shallowLeafHeadBottomY : leafArchSpringY + const sideHeight = Math.max(sideTop - sideBottom, 0) + + if (cpY > 0) { + addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafBottom + cpY / 2, 0) + } + if (cpX > 0 && sideHeight > 0.01) { + addLeafBox( + baseMaterial, + cpX, + sideHeight, + leafDepth, + -leafW / 2 + cpX / 2, + sideBottom + sideHeight / 2, + 0, + ) + addLeafBox( + baseMaterial, + cpX, + sideHeight, + leafDepth, + leafW / 2 - cpX / 2, + sideBottom + sideHeight / 2, + 0, + ) + } + addLeafShape( + useShallowLeafHeadBar + ? createArchHeadBarShape(leafW, shallowLeafHeadBottomY, leafArchSpringY, leafTop) + : createArchBandShape( + leafW, + leafArchSpringY, + leafTop, + leafInnerSpringY, + leafInnerTopY, + cpX, + ), + baseMaterial, + leafDepth, + ) + } else if (hasLeafContent && openingShape === 'rounded') { + addLeafShape( + createRoundedLeafFrameShape(leafW, leafBottom, leafTop, leafTopRadii, cpX, cpY), + baseMaterial, + leafDepth, + ) + } else if (hasLeafContent && cpY > 0) { // Top strip addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) // Bottom strip addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) } - if (hasLeafContent && cpX > 0) { + if (hasLeafContent && !usesShapedLeaf && cpX > 0) { const innerH = leafH - 2 * cpY // Left strip addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) @@ -205,9 +688,12 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const contentTop = leafCenterY + contentH / 2 let segY = contentTop - for (const seg of segments) { + for (let segIndex = 0; segIndex < segments.length; segIndex += 1) { + const seg = segments[segIndex]! const segH = (seg.heightRatio / totalRatio) * contentH const segCenterY = segY - segH / 2 + const segTop = segY + const segBottom = segY - segH const numCols = seg.columnRatios.length const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) @@ -228,15 +714,24 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { cx = -contentW / 2 for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! - addLeafBox( - baseMaterial, - seg.dividerThickness, - segH, - leafDepth + 0.001, - cx + seg.dividerThickness / 2, - segCenterY, - 0, - ) + if (usesShapedLeaf) { + const dividerLeft = cx + const dividerRight = cx + seg.dividerThickness + const dividerShape = createLeafCellShape(dividerLeft, dividerRight, segBottom, segTop) + if (dividerShape) { + addLeafShape(dividerShape, baseMaterial, 0.012, leafDepth / 2 + 0.006) + } + } else { + addLeafBox( + baseMaterial, + seg.dividerThickness, + segH, + leafDepth + 0.001, + cx + seg.dividerThickness / 2, + segCenterY, + 0, + ) + } cx += seg.dividerThickness } } @@ -245,27 +740,61 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { for (let c = 0; c < numCols; c++) { const colW = colWidths[c]! const colX = colXCenters[c]! + const cellLeft = colX - colW / 2 + const cellRight = colX + colW / 2 if (seg.type === 'glass') { - // Glass only — no opaque backing so it's truly transparent const glassDepth = Math.max(0.004, leafDepth * 0.15) - addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + if (usesShapedLeaf) { + const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop) + if (shape) + addLeafShape(shape, glassMaterial, glassDepth, leafDepth / 2 + glassDepth / 2 + 0.004) + } else { + // Glass only — no opaque backing so it's truly transparent + addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + } } else if (seg.type === 'panel') { - // Opaque leaf backing for this column - addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + if (usesShapedLeaf) { + const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop) + if (shape) addLeafShape(shape, baseMaterial, leafDepth) + } else { + // Opaque leaf backing for this column + addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + } // Raised panel detail const panelW = colW - 2 * seg.panelInset const panelH = segH - 2 * seg.panelInset if (panelW > 0.01 && panelH > 0.01) { const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) const panelZ = leafDepth / 2 + effectiveDepth / 2 - addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + if (usesShapedLeaf) { + const shape = createLeafCellShape( + colX - panelW / 2, + colX + panelW / 2, + segCenterY - panelH / 2, + segCenterY + panelH / 2, + ) + if (shape) addLeafShape(shape, baseMaterial, effectiveDepth, panelZ) + } else { + addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + } } } else { // 'empty' leaves the opening unfilled } } + if (usesShapedLeaf && segIndex < segments.length - 1) { + const railThickness = Math.min(Math.max(cpY, 0.02), Math.max(segH * 0.35, 0.02)) + const railShape = createLeafCellShape( + -contentW / 2, + contentW / 2, + segBottom - railThickness / 2, + segBottom + railThickness / 2, + ) + if (railShape) addLeafShape(railShape, baseMaterial, 0.012, leafDepth / 2 + 0.006) + } + segY -= segH } @@ -308,8 +837,6 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const hingeW = 0.024 const hingeD = leafDepth + 0.016 // Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top - const leafBottom = leafCenterY - leafH / 2 - const leafTop = leafCenterY + leafH / 2 addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ) addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ) addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ) @@ -327,6 +854,40 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { mesh.add(cutout) } cutout.geometry.dispose() - cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + if (node.openingShape === 'arch') { + cutout.geometry = new THREE.ExtrudeGeometry( + createArchShape( + -node.width / 2, + node.width / 2, + -node.height / 2, + node.height / 2, + getClampedArchHeight(node.width, node.height, node.archHeight), + ), + { + depth: 1, + bevelEnabled: false, + curveSegments: 24, + }, + ) + cutout.geometry.translate(0, 0, -0.5) + } else if (node.openingShape === 'rounded') { + cutout.geometry = new THREE.ExtrudeGeometry( + createRoundedTopShape( + -node.width / 2, + node.width / 2, + -node.height / 2, + node.height / 2, + getDoorTopRadii(node, node.width, node.height), + ), + { + depth: 1, + bevelEnabled: false, + curveSegments: 24, + }, + ) + cutout.geometry.translate(0, 0, -0.5) + } else { + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + } cutout.visible = false } diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 98c98467e..31490322d 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -1,14 +1,10 @@ -import { useFrame } from '@react-three/fiber' -import * as THREE from 'three' -import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' -import { computeBoundsTree } from 'three-mesh-bvh' import { - calculateLevelMiters, type AnyNode, type AnyNodeId, + calculateLevelMiters, + DEFAULT_WALL_HEIGHT, type DoorNode, getAdjacentWallIds, - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallMiterBoundaryPoints, getWallPlanFootprint, @@ -21,10 +17,14 @@ import { sceneRegistry, spatialGridManager, useScene, - type WallNode, type WallMiterData, + type WallNode, type WindowNode, } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' +import { computeBoundsTree } from 'three-mesh-bvh' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() @@ -560,7 +560,13 @@ function collectCutoutBrushes( if ( (child.type === 'door' && child.openingKind === 'opening') || - (child.type === 'window' && child.openingKind === 'opening') + (child.type === 'door' && + child.openingKind === 'door' && + (child.openingShape === 'arch' || child.openingShape === 'rounded')) || + (child.type === 'window' && child.openingKind === 'opening') || + (child.type === 'window' && + child.openingKind === 'window' && + (child.openingShape === 'arch' || child.openingShape === 'rounded')) ) { brushes.push(createShapedOpeningCutoutBrush(child, wallThickness)) continue @@ -668,11 +674,17 @@ function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape if (opening.openingShape === 'arch') { const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height) const springY = top - archHeight + const segments = 32 shape.moveTo(left, bottom) shape.lineTo(right, bottom) shape.lineTo(right, springY) - shape.quadraticCurveTo(centerX, top, left, springY) + for (let index = 1; index <= segments; index += 1) { + const x = right + (left - right) * (index / segments) + const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1) + const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0)) + shape.lineTo(x, y) + } shape.lineTo(left, bottom) shape.closePath() return shape diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 01a77f430..81382b1e7 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,10 +1,5 @@ +import { type AnyNodeId, sceneRegistry, useScene, type WindowNode } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' -import { - type AnyNodeId, - sceneRegistry, - useScene, - type WindowNode, -} from '@pascal-app/core' import * as THREE from 'three' import { baseMaterial, glassMaterial } from '../../lib/materials' @@ -55,6 +50,521 @@ function addBox( parent.add(m) } +function addShape( + parent: THREE.Object3D, + material: THREE.Material, + shape: THREE.Shape, + depth: number, + z = 0, +) { + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: false, + curveSegments: 24, + }) + geometry.translate(0, 0, -depth / 2 + z) + const mesh = new THREE.Mesh(geometry, material) + parent.add(mesh) +} + +function createRectShape(left: number, right: number, bottom: number, top: number) { + const shape = new THREE.Shape() + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, top) + shape.lineTo(left, top) + shape.closePath() + return shape +} + +type CornerRadii = { + topLeft: number + topRight: number + bottomRight: number + bottomLeft: number +} + +function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii { + const next = { ...radii } + const scale = Math.min( + 1, + width / Math.max(next.topLeft + next.topRight, 1e-6), + width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), + height / Math.max(next.topLeft + next.bottomLeft, 1e-6), + height / Math.max(next.topRight + next.bottomRight, 1e-6), + ) + + if (scale < 1) { + next.topLeft *= scale + next.topRight *= scale + next.bottomRight *= scale + next.bottomLeft *= scale + } + + return next +} + +function getWindowRoundedRadii(node: WindowNode, width: number, height: number): CornerRadii { + if (node.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = + node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] + return normalizeCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + bottomRight: Math.max(bottomRight, 0), + bottomLeft: Math.max(bottomLeft, 0), + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height / 2) + const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius } +} + +function insetCornerRadii(radii: CornerRadii, inset: number, width: number, height: number) { + return normalizeCornerRadii( + { + topLeft: Math.max(radii.topLeft - inset, 0), + topRight: Math.max(radii.topRight - inset, 0), + bottomRight: Math.max(radii.bottomRight - inset, 0), + bottomLeft: Math.max(radii.bottomLeft - inset, 0), + }, + width, + height, + ) +} + +function createRoundedShape( + left: number, + right: number, + bottom: number, + top: number, + radii: CornerRadii, +) { + const shape = new THREE.Shape() + const { topLeft, topRight, bottomRight, bottomLeft } = radii + + shape.moveTo(left + bottomLeft, bottom) + shape.lineTo(right - bottomRight, bottom) + if (bottomRight > 1e-6) { + shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false) + } else { + shape.lineTo(right, bottom) + } + + shape.lineTo(right, top - topRight) + if (topRight > 1e-6) { + shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false) + } else { + shape.lineTo(right, top) + } + + shape.lineTo(left + topLeft, top) + if (topLeft > 1e-6) { + shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false) + } else { + shape.lineTo(left, top) + } + + shape.lineTo(left, bottom + bottomLeft) + if (bottomLeft > 1e-6) { + shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false) + } else { + shape.lineTo(left, bottom) + } + + shape.closePath() + return shape +} + +function createRoundedFrameShape( + width: number, + height: number, + frameThickness: number, + outerRadii: CornerRadii, +) { + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const outer = createRoundedShape(-halfWidth, halfWidth, bottom, top, outerRadii) + const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005) + + if (inset <= 0.001) return outer + + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerBottom = bottom + inset + const innerTop = top - inset + const innerRadii = insetCornerRadii( + outerRadii, + inset, + innerRight - innerLeft, + innerTop - innerBottom, + ) + const holeShape = createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii) + const hole = new THREE.Path(holeShape.getPoints(32).reverse()) + outer.holes.push(hole) + + return outer +} + +function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) { + return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01)) +} + +function createArchShape( + left: number, + right: number, + bottom: number, + top: number, + archHeight: number, +) { + const centerX = (left + right) / 2 + const halfWidth = (right - left) / 2 + const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight) + const springY = top - clampedArchHeight + const shape = new THREE.Shape() + const segments = 32 + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, springY) + for (let index = 1; index <= segments; index += 1) { + const x = right + (left - right) * (index / segments) + shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight)) + } + shape.lineTo(left, bottom) + shape.closePath() + return shape +} + +function createArchedFrameShape( + width: number, + height: number, + archHeight: number, + frameThickness: number, +) { + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight) + const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005) + + if (inset <= 0.001) return outer + + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerBottom = bottom + inset + const innerTop = top - inset + const innerArchHeight = getClampedArchHeight( + innerRight - innerLeft, + innerTop - innerBottom, + archHeight - inset, + ) + const hole = new THREE.Path( + createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight) + .getPoints(32) + .reverse(), + ) + outer.holes.push(hole) + + return outer +} + +function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) { + if (halfWidth <= 1e-6) return springY + const t = Math.min(Math.abs(x) / halfWidth, 1) + return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0)) +} + +function getArchedOpeningHalfWidthAtY( + y: number, + halfWidth: number, + springY: number, + archHeight: number, +) { + if (y <= springY || archHeight <= 1e-6) return halfWidth + const normalizedY = Math.min(Math.max((y - springY) / archHeight, 0), 1) + return halfWidth * Math.sqrt(Math.max(1 - normalizedY * normalizedY, 0)) +} + +function getRoundedBoundaryYAtX( + x: number, + left: number, + right: number, + top: number, + radii: CornerRadii, +) { + if (radii.topLeft > 1e-6 && x < left + radii.topLeft) { + const centerX = left + radii.topLeft + const centerY = top - radii.topLeft + const dx = x - centerX + return centerY + Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dx * dx, 0)) + } + + if (radii.topRight > 1e-6 && x > right - radii.topRight) { + const centerX = right - radii.topRight + const centerY = top - radii.topRight + const dx = x - centerX + return centerY + Math.sqrt(Math.max(radii.topRight * radii.topRight - dx * dx, 0)) + } + + return top +} + +function getRoundedHorizontalBoundsAtY( + y: number, + left: number, + right: number, + top: number, + radii: CornerRadii, +) { + let minX = left + let maxX = right + + if (radii.topLeft > 1e-6 && y > top - radii.topLeft) { + const centerX = left + radii.topLeft + const centerY = top - radii.topLeft + const dy = y - centerY + minX = centerX - Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dy * dy, 0)) + } + + if (radii.topRight > 1e-6 && y > top - radii.topRight) { + const centerX = right - radii.topRight + const centerY = top - radii.topRight + const dy = y - centerY + maxX = centerX + Math.sqrt(Math.max(radii.topRight * radii.topRight - dy * dy, 0)) + } + + return { minX, maxX } +} + +function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { + const { + width, + height, + frameDepth, + frameThickness, + columnRatios, + rowRatios, + columnDividerThickness, + rowDividerThickness, + sill, + sillDepth, + sillThickness, + } = node + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const outerRadii = getWindowRoundedRadii(node, width, height) + const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)) + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerBottom = bottom + inset + const innerTop = top - inset + const innerW = innerRight - innerLeft + const innerH = innerTop - innerBottom + const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH) + + addShape( + mesh, + baseMaterial, + createRoundedFrameShape(width, height, inset, outerRadii), + frameDepth, + ) + + if (innerW > 0.01 && innerH > 0.01) { + const glassDepth = Math.max(0.004, frameDepth * 0.08) + addShape( + mesh, + glassMaterial, + createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii), + glassDepth, + ) + + const numCols = columnRatios.length + const numRows = rowRatios.length + const usableW = innerW - (numCols - 1) * columnDividerThickness + const usableH = innerH - (numRows - 1) * rowDividerThickness + const colSum = columnRatios.reduce((a, b) => a + b, 0) + const rowSum = rowRatios.reduce((a, b) => a + b, 0) + const colWidths = columnRatios.map((r) => (r / colSum) * usableW) + const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) + + let x = innerLeft + for (let c = 0; c < numCols - 1; c++) { + x += colWidths[c]! + const x1 = x + const x2 = x + columnDividerThickness + const dividerTop = Math.min( + getRoundedBoundaryYAtX(x1, innerLeft, innerRight, innerTop, innerRadii), + getRoundedBoundaryYAtX(x2, innerLeft, innerRight, innerTop, innerRadii), + ) + if (dividerTop > innerBottom + 0.01) { + addShape( + mesh, + baseMaterial, + createRectShape(x1, x2, innerBottom, dividerTop), + frameDepth + 0.001, + ) + } + x += columnDividerThickness + } + + let y = innerTop + for (let r = 0; r < numRows - 1; r++) { + y -= rowHeights[r]! + const yTop = y + const yBottom = y - rowDividerThickness + const { minX, maxX } = getRoundedHorizontalBoundsAtY( + yTop, + innerLeft, + innerRight, + innerTop, + innerRadii, + ) + if (maxX - minX > 0.01 && yTop > innerBottom) { + addShape( + mesh, + baseMaterial, + createRectShape(minX, maxX, Math.max(yBottom, innerBottom), yTop), + frameDepth + 0.001, + ) + } + y -= rowDividerThickness + } + } + + if (sill) { + const sillW = width + sillDepth * 0.4 + const sillZ = frameDepth / 2 + sillDepth / 2 + addBox( + mesh, + baseMaterial, + sillW, + sillThickness, + sillDepth, + 0, + -height / 2 - sillThickness / 2, + sillZ, + ) + } +} + +function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { + const { + width, + height, + frameDepth, + frameThickness, + columnRatios, + rowRatios, + columnDividerThickness, + rowDividerThickness, + sill, + sillDepth, + sillThickness, + } = node + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const archHeight = getClampedArchHeight(width, height, node.archHeight) + const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)) + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerBottom = bottom + inset + const innerTop = top - inset + const innerW = innerRight - innerLeft + const innerH = innerTop - innerBottom + const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset) + const innerSpringY = innerTop - innerArchHeight + + addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth) + + if (innerW > 0.01 && innerH > 0.01) { + const glassDepth = Math.max(0.004, frameDepth * 0.08) + addShape( + mesh, + glassMaterial, + createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight), + glassDepth, + ) + + const numCols = columnRatios.length + const numRows = rowRatios.length + const usableW = innerW - (numCols - 1) * columnDividerThickness + const usableH = innerH - (numRows - 1) * rowDividerThickness + const colSum = columnRatios.reduce((a, b) => a + b, 0) + const rowSum = rowRatios.reduce((a, b) => a + b, 0) + const colWidths = columnRatios.map((r) => (r / colSum) * usableW) + const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) + const innerHalfWidth = innerW / 2 + + let x = innerLeft + for (let c = 0; c < numCols - 1; c++) { + x += colWidths[c]! + const x1 = x + const x2 = x + columnDividerThickness + const dividerTop = Math.min( + getArchBoundaryY(x1, innerHalfWidth, innerSpringY, innerArchHeight), + getArchBoundaryY(x2, innerHalfWidth, innerSpringY, innerArchHeight), + ) + if (dividerTop > innerBottom + 0.01) { + addShape( + mesh, + baseMaterial, + createRectShape(x1, x2, innerBottom, dividerTop), + frameDepth + 0.001, + ) + } + x += columnDividerThickness + } + + let y = innerTop + for (let r = 0; r < numRows - 1; r++) { + y -= rowHeights[r]! + const yTop = y + const yBottom = y - rowDividerThickness + const halfAtTop = getArchedOpeningHalfWidthAtY( + yTop, + innerHalfWidth, + innerSpringY, + innerArchHeight, + ) + const x1 = -halfAtTop + const x2 = halfAtTop + if (x2 - x1 > 0.01 && yTop > innerBottom) { + addShape( + mesh, + baseMaterial, + createRectShape(x1, x2, Math.max(yBottom, innerBottom), yTop), + frameDepth + 0.001, + ) + } + y -= rowDividerThickness + } + } + + if (sill) { + const sillW = width + sillDepth * 0.4 + const sillZ = frameDepth / 2 + sillDepth / 2 + addBox( + mesh, + baseMaterial, + sillW, + sillThickness, + sillDepth, + 0, + -height / 2 - sillThickness / 2, + sillZ, + ) + } +} + function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -85,6 +595,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { sillDepth, sillThickness, openingKind, + openingShape, } = node if (openingKind === 'opening') { @@ -92,6 +603,18 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { return } + if (openingShape === 'arch') { + addArchedWindowVisuals(node, mesh) + syncWindowCutout(node, mesh) + return + } + + if (openingShape === 'rounded') { + addRoundedWindowVisuals(node, mesh) + syncWindowCutout(node, mesh) + return + } + const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness @@ -252,6 +775,40 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { mesh.add(cutout) } cutout.geometry.dispose() - cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + if (node.openingShape === 'arch') { + cutout.geometry = new THREE.ExtrudeGeometry( + createArchShape( + -node.width / 2, + node.width / 2, + -node.height / 2, + node.height / 2, + getClampedArchHeight(node.width, node.height, node.archHeight), + ), + { + depth: 1, + bevelEnabled: false, + curveSegments: 24, + }, + ) + cutout.geometry.translate(0, 0, -0.5) + } else if (node.openingShape === 'rounded') { + cutout.geometry = new THREE.ExtrudeGeometry( + createRoundedShape( + -node.width / 2, + node.width / 2, + -node.height / 2, + node.height / 2, + getWindowRoundedRadii(node, node.width, node.height), + ), + { + depth: 1, + bevelEnabled: false, + curveSegments: 24, + }, + ) + cutout.geometry.translate(0, 0, -0.5) + } else { + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + } cutout.visible = false }