Merge pull request #292 from sudhir9297/feat/mon-4-feat-fix
feat: column node with door/window nodes improvement
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -4,9 +4,9 @@ import type { Object3D } from 'three'
|
|||||||
import type {
|
import type {
|
||||||
BuildingNode,
|
BuildingNode,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
|
ColumnNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
GuideNode,
|
|
||||||
ItemNode,
|
ItemNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
@@ -57,6 +57,7 @@ export type ZoneEvent = NodeEvent<ZoneNode>
|
|||||||
export type SlabEvent = NodeEvent<SlabNode>
|
export type SlabEvent = NodeEvent<SlabNode>
|
||||||
export type SpawnEvent = NodeEvent<SpawnNode>
|
export type SpawnEvent = NodeEvent<SpawnNode>
|
||||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||||
|
export type ColumnEvent = NodeEvent<ColumnNode>
|
||||||
export type RoofEvent = NodeEvent<RoofNode>
|
export type RoofEvent = NodeEvent<RoofNode>
|
||||||
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
||||||
export type StairEvent = NodeEvent<StairNode>
|
export type StairEvent = NodeEvent<StairNode>
|
||||||
@@ -131,12 +132,6 @@ type ToolEvents = {
|
|||||||
'tool:cancel': undefined
|
'tool:cancel': undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
type GuideEvents = {
|
|
||||||
'guide:set-reference-scale': { guideId: GuideNode['id'] }
|
|
||||||
'guide:cancel-reference-scale': undefined
|
|
||||||
'guide:deleted': { guideId: GuideNode['id'] }
|
|
||||||
}
|
|
||||||
|
|
||||||
type PresetEvents = {
|
type PresetEvents = {
|
||||||
'preset:generate-thumbnail': { presetId: string; nodeId: string }
|
'preset:generate-thumbnail': { presetId: string; nodeId: string }
|
||||||
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
|
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
|
||||||
@@ -169,6 +164,7 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'slab', SlabEvent> &
|
NodeEvents<'slab', SlabEvent> &
|
||||||
NodeEvents<'spawn', SpawnEvent> &
|
NodeEvents<'spawn', SpawnEvent> &
|
||||||
NodeEvents<'ceiling', CeilingEvent> &
|
NodeEvents<'ceiling', CeilingEvent> &
|
||||||
|
NodeEvents<'column', ColumnEvent> &
|
||||||
NodeEvents<'roof', RoofEvent> &
|
NodeEvents<'roof', RoofEvent> &
|
||||||
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
||||||
NodeEvents<'stair', StairEvent> &
|
NodeEvents<'stair', StairEvent> &
|
||||||
@@ -177,7 +173,6 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'door', DoorEvent> &
|
NodeEvents<'door', DoorEvent> &
|
||||||
CameraControlEvents &
|
CameraControlEvents &
|
||||||
ToolEvents &
|
ToolEvents &
|
||||||
GuideEvents &
|
|
||||||
PresetEvents &
|
PresetEvents &
|
||||||
ThumbnailEvents &
|
ThumbnailEvents &
|
||||||
SnapshotEvents &
|
SnapshotEvents &
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const sceneRegistry = {
|
|||||||
site: new Set<string>(),
|
site: new Set<string>(),
|
||||||
building: new Set<string>(),
|
building: new Set<string>(),
|
||||||
ceiling: new Set<string>(),
|
ceiling: new Set<string>(),
|
||||||
|
column: new Set<string>(),
|
||||||
level: new Set<string>(),
|
level: new Set<string>(),
|
||||||
wall: new Set<string>(),
|
wall: new Set<string>(),
|
||||||
fence: new Set<string>(),
|
fence: new Set<string>(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type {
|
|||||||
CameraControlEvent,
|
CameraControlEvent,
|
||||||
CameraControlFitSceneEvent,
|
CameraControlFitSceneEvent,
|
||||||
CeilingEvent,
|
CeilingEvent,
|
||||||
|
ColumnEvent,
|
||||||
DoorEvent,
|
DoorEvent,
|
||||||
EventSuffix,
|
EventSuffix,
|
||||||
FenceEvent,
|
FenceEvent,
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export {
|
|||||||
} from './material'
|
} from './material'
|
||||||
export { BuildingNode } from './nodes/building'
|
export { BuildingNode } from './nodes/building'
|
||||||
export { CeilingNode } from './nodes/ceiling'
|
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 { DoorNode, DoorSegment } from './nodes/door'
|
||||||
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||||
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
||||||
@@ -50,8 +64,8 @@ export { ScanNode } from './nodes/scan'
|
|||||||
// Nodes
|
// Nodes
|
||||||
export { SiteNode } from './nodes/site'
|
export { SiteNode } from './nodes/site'
|
||||||
export { SlabNode } from './nodes/slab'
|
export { SlabNode } from './nodes/slab'
|
||||||
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
|
|
||||||
export { SpawnNode } from './nodes/spawn'
|
export { SpawnNode } from './nodes/spawn'
|
||||||
|
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
|
||||||
export {
|
export {
|
||||||
getEffectiveStairSurfaceMaterial,
|
getEffectiveStairSurfaceMaterial,
|
||||||
StairNode,
|
StairNode,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const MaterialTarget = z.enum([
|
|||||||
'stair',
|
'stair',
|
||||||
'stair-segment',
|
'stair-segment',
|
||||||
'fence',
|
'fence',
|
||||||
|
'column',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'door',
|
'door',
|
||||||
|
|||||||
@@ -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<typeof ColumnStyle>
|
||||||
|
export type ColumnCrossSection = z.infer<typeof ColumnCrossSection>
|
||||||
|
export type ColumnShaftProfile = z.infer<typeof ColumnShaftProfile>
|
||||||
|
export type ColumnShaftDetail = z.infer<typeof ColumnShaftDetail>
|
||||||
|
export type ColumnPanelShape = z.infer<typeof ColumnPanelShape>
|
||||||
|
export type ColumnBaseStyle = z.infer<typeof ColumnBaseStyle>
|
||||||
|
export type ColumnCapitalStyle = z.infer<typeof ColumnCapitalStyle>
|
||||||
|
export type ColumnRingPlacement = z.infer<typeof ColumnRingPlacement>
|
||||||
|
export type ColumnCarvingPlacement = z.infer<typeof ColumnCarvingPlacement>
|
||||||
|
|
||||||
|
export const ColumnNode = BaseNode.extend({
|
||||||
|
id: objectId('column'),
|
||||||
|
type: nodeType('column'),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
style: ColumnStyle.default('plain'),
|
||||||
|
crossSection: ColumnCrossSection.default('round'),
|
||||||
|
height: z.number().positive().default(2.8),
|
||||||
|
radius: z.number().positive().default(0.22),
|
||||||
|
width: z.number().positive().default(0.44),
|
||||||
|
depth: z.number().positive().default(0.44),
|
||||||
|
edgeSoftness: z.number().min(0).max(0.12).default(0.025),
|
||||||
|
baseHeight: z.number().min(0).default(0.18),
|
||||||
|
capitalHeight: z.number().min(0).default(0.18),
|
||||||
|
shaftProfile: ColumnShaftProfile.default('straight'),
|
||||||
|
shaftTaper: z.number().min(0).max(0.85).default(0),
|
||||||
|
shaftBulge: z.number().min(-0.5).max(0.8).default(0),
|
||||||
|
shaftStartScale: z.number().min(0.2).max(2).default(0.72),
|
||||||
|
shaftEndScale: z.number().min(0.2).max(2).default(0.72),
|
||||||
|
shaftSegmentCount: z.number().int().min(1).max(64).default(24),
|
||||||
|
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<string, { label: string } & Partial<z.input<typeof ColumnNode>>>
|
||||||
|
|
||||||
|
export type ColumnPresetId = keyof typeof COLUMN_PRESETS
|
||||||
|
|
||||||
|
export type ColumnNode = z.infer<typeof ColumnNode>
|
||||||
@@ -2,6 +2,7 @@ import dedent from 'dedent'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
import { CeilingNode } from './ceiling'
|
import { CeilingNode } from './ceiling'
|
||||||
|
import { ColumnNode } from './column'
|
||||||
import { FenceNode } from './fence'
|
import { FenceNode } from './fence'
|
||||||
import { GuideNode } from './guide'
|
import { GuideNode } from './guide'
|
||||||
import { ItemNode } from './item'
|
import { ItemNode } from './item'
|
||||||
@@ -21,6 +22,7 @@ export const LevelNode = BaseNode.extend({
|
|||||||
z.union([
|
z.union([
|
||||||
WallNode.shape.id,
|
WallNode.shape.id,
|
||||||
FenceNode.shape.id,
|
FenceNode.shape.id,
|
||||||
|
ColumnNode.shape.id,
|
||||||
ItemNode.shape.id,
|
ItemNode.shape.id,
|
||||||
ZoneNode.shape.id,
|
ZoneNode.shape.id,
|
||||||
SlabNode.shape.id,
|
SlabNode.shape.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import z from 'zod'
|
import z from 'zod'
|
||||||
import { BuildingNode } from './nodes/building'
|
import { BuildingNode } from './nodes/building'
|
||||||
import { CeilingNode } from './nodes/ceiling'
|
import { CeilingNode } from './nodes/ceiling'
|
||||||
|
import { ColumnNode } from './nodes/column'
|
||||||
import { DoorNode } from './nodes/door'
|
import { DoorNode } from './nodes/door'
|
||||||
import { FenceNode } from './nodes/fence'
|
import { FenceNode } from './nodes/fence'
|
||||||
import { GuideNode } from './nodes/guide'
|
import { GuideNode } from './nodes/guide'
|
||||||
@@ -22,6 +23,7 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
SiteNode,
|
SiteNode,
|
||||||
BuildingNode,
|
BuildingNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
|
ColumnNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
ColumnNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
generateId,
|
generateId,
|
||||||
@@ -25,8 +26,8 @@ import { Move } from 'lucide-react'
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
|
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import { NodeActionMenu } from './node-action-menu'
|
import { NodeActionMenu } from './node-action-menu'
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ const ALLOWED_TYPES = [
|
|||||||
'stair-segment',
|
'stair-segment',
|
||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
|
'column',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'spawn',
|
'spawn',
|
||||||
@@ -147,10 +149,7 @@ export function FloatingActionMenu() {
|
|||||||
node.type === 'wall'
|
node.type === 'wall'
|
||||||
? obj.localToWorld(
|
? obj.localToWorld(
|
||||||
new THREE.Vector3(
|
new THREE.Vector3(
|
||||||
Math.hypot(
|
Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]),
|
||||||
segment.end[0] - segment.start[0],
|
|
||||||
segment.end[1] - segment.start[1],
|
|
||||||
),
|
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
@@ -186,6 +185,7 @@ export function FloatingActionMenu() {
|
|||||||
node.type === 'door' ||
|
node.type === 'door' ||
|
||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
|
node.type === 'column' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'spawn' ||
|
node.type === 'spawn' ||
|
||||||
@@ -263,6 +263,8 @@ export function FloatingActionMenu() {
|
|||||||
duplicate = WindowNode.parse(duplicateInfo)
|
duplicate = WindowNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'item') {
|
} else if (node.type === 'item') {
|
||||||
duplicate = ItemNode.parse(duplicateInfo)
|
duplicate = ItemNode.parse(duplicateInfo)
|
||||||
|
} else if (node.type === 'column') {
|
||||||
|
duplicate = ColumnNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'wall') {
|
} else if (node.type === 'wall') {
|
||||||
duplicate = WallNode.parse(duplicateInfo)
|
duplicate = WallNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'fence') {
|
} else if (node.type === 'fence') {
|
||||||
@@ -323,6 +325,7 @@ export function FloatingActionMenu() {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
duplicate.type === 'item' ||
|
duplicate.type === 'item' ||
|
||||||
|
duplicate.type === 'column' ||
|
||||||
duplicate.type === 'wall' ||
|
duplicate.type === 'wall' ||
|
||||||
duplicate.type === 'fence' ||
|
duplicate.type === 'fence' ||
|
||||||
duplicate.type === 'window' ||
|
duplicate.type === 'window' ||
|
||||||
@@ -335,7 +338,7 @@ export function FloatingActionMenu() {
|
|||||||
} else if (duplicate.type === 'stair') {
|
} else if (duplicate.type === 'stair') {
|
||||||
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
||||||
}
|
}
|
||||||
if (duplicate.type !== 'stair' && duplicate.type !== 'roof') {
|
if (duplicate.type !== 'stair') {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
rotatePlanVector as rotateSharedPlanVector,
|
rotatePlanVector as rotateSharedPlanVector,
|
||||||
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
||||||
} from '../../lib/floorplan'
|
} from '../../lib/floorplan'
|
||||||
|
import { guideEmitter } from '../../lib/guide-events'
|
||||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||||
@@ -9116,9 +9117,9 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter.on('guide:set-reference-scale', handleSetReferenceScale)
|
guideEmitter.on('guide:set-reference-scale', handleSetReferenceScale)
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('guide:set-reference-scale', handleSetReferenceScale)
|
guideEmitter.off('guide:set-reference-scale', handleSetReferenceScale)
|
||||||
}
|
}
|
||||||
}, [startReferenceScaleForGuide])
|
}, [startReferenceScaleForGuide])
|
||||||
|
|
||||||
@@ -9128,9 +9129,9 @@ export function FloorplanPanel() {
|
|||||||
setPendingReferenceScale(null)
|
setPendingReferenceScale(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter.on('guide:cancel-reference-scale', handleCancel)
|
guideEmitter.on('guide:cancel-reference-scale', handleCancel)
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('guide:cancel-reference-scale', handleCancel)
|
guideEmitter.off('guide:cancel-reference-scale', handleCancel)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -9145,9 +9146,9 @@ export function FloorplanPanel() {
|
|||||||
clearGuideUi(payload.guideId)
|
clearGuideUi(payload.guideId)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter.on('guide:deleted', handleDeleted)
|
guideEmitter.on('guide:deleted', handleDeleted)
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('guide:deleted', handleDeleted)
|
guideEmitter.off('guide:deleted', handleDeleted)
|
||||||
}
|
}
|
||||||
}, [clearGuideUi])
|
}, [clearGuideUi])
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
type ColumnNode,
|
||||||
emitter,
|
emitter,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
getMaterialPresetByRef,
|
getMaterialPresetByRef,
|
||||||
@@ -65,6 +66,7 @@ type SelectableNodeType =
|
|||||||
| 'wall'
|
| 'wall'
|
||||||
| 'fence'
|
| 'fence'
|
||||||
| 'item'
|
| 'item'
|
||||||
|
| 'column'
|
||||||
| 'building'
|
| 'building'
|
||||||
| 'zone'
|
| 'zone'
|
||||||
| 'slab'
|
| 'slab'
|
||||||
@@ -333,7 +335,7 @@ function applyStairPaintPreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applySingleSurfacePaintPreview(
|
function applySingleSurfacePaintPreview(
|
||||||
node: FenceNode | SlabNode | CeilingNode,
|
node: FenceNode | ColumnNode | SlabNode | CeilingNode,
|
||||||
material: ActivePaintMaterial,
|
material: ActivePaintMaterial,
|
||||||
): PaintPreviewCleanup | null {
|
): PaintPreviewCleanup | null {
|
||||||
if (node.type === 'ceiling') {
|
if (node.type === 'ceiling') {
|
||||||
@@ -377,12 +379,32 @@ function applySingleSurfacePaintPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mesh = getRegisteredMesh(node.id)
|
const registeredObject = getRegisteredNodeObject(node.id)
|
||||||
if (!mesh) return null
|
const mesh =
|
||||||
|
registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null
|
||||||
|
|
||||||
const previewMaterial = getSingleSurfacePreviewMaterial(material)
|
const previewMaterial = getSingleSurfacePreviewMaterial(material)
|
||||||
if (!previewMaterial) return null
|
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') {
|
if (node.type === 'slab') {
|
||||||
const slabMaterial = previewMaterial.clone()
|
const slabMaterial = previewMaterial.clone()
|
||||||
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
|
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
|
||||||
@@ -542,6 +564,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
|
'column',
|
||||||
'zone',
|
'zone',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
@@ -595,6 +618,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
if (
|
if (
|
||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
|
node.type === 'column' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
@@ -658,6 +682,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|||||||
if (
|
if (
|
||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
|
node.type === 'column' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
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)
|
const compatible = hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -870,17 +900,16 @@ export const SelectionManager = () => {
|
|||||||
.getState()
|
.getState()
|
||||||
.updateNode(
|
.updateNode(
|
||||||
node.id as AnyNodeId,
|
node.id as AnyNodeId,
|
||||||
buildSingleSurfaceMaterialPatch<FenceNode | SlabNode | CeilingNode>(
|
buildSingleSurfaceMaterialPatch<
|
||||||
activePaintMaterial.material,
|
FenceNode | ColumnNode | SlabNode | CeilingNode
|
||||||
activePaintMaterial.materialPreset,
|
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
preview: compatible
|
preview: compatible
|
||||||
? () =>
|
? () =>
|
||||||
applySingleSurfacePaintPreview(
|
applySingleSurfacePaintPreview(
|
||||||
node as FenceNode | SlabNode | CeilingNode,
|
node as FenceNode | ColumnNode | SlabNode | CeilingNode,
|
||||||
activePaintMaterial,
|
activePaintMaterial,
|
||||||
)
|
)
|
||||||
: () => previewCursor('not-allowed'),
|
: () => previewCursor('not-allowed'),
|
||||||
@@ -963,6 +992,7 @@ export const SelectionManager = () => {
|
|||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
|
'column',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
@@ -1131,6 +1161,7 @@ export const SelectionManager = () => {
|
|||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
|
'column',
|
||||||
'building',
|
'building',
|
||||||
'zone',
|
'zone',
|
||||||
'slab',
|
'slab',
|
||||||
@@ -1227,6 +1258,7 @@ export const SelectionManager = () => {
|
|||||||
} else if (
|
} else if (
|
||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
|
node.type === 'column' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
@@ -1279,6 +1311,7 @@ export const SelectionManager = () => {
|
|||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
|
'column',
|
||||||
'building',
|
'building',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
@@ -1352,6 +1385,7 @@ export const SelectionManager = () => {
|
|||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
|
'column',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
calculateLevelMiters,
|
calculateLevelMiters,
|
||||||
DEFAULT_WALL_HEIGHT,
|
DEFAULT_WALL_HEIGHT,
|
||||||
|
getScaledDimensions,
|
||||||
getWallCurveLength,
|
getWallCurveLength,
|
||||||
getWallMiterBoundaryPoints,
|
getWallMiterBoundaryPoints,
|
||||||
getWallPlanFootprint,
|
getWallPlanFootprint,
|
||||||
getWallSurfacePolygon,
|
getWallSurfacePolygon,
|
||||||
|
type ItemNode,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
type Point2D,
|
type Point2D,
|
||||||
pointToKey,
|
pointToKey,
|
||||||
@@ -27,6 +29,8 @@ const GUIDE_Y_OFFSET = 0.08
|
|||||||
const LABEL_LIFT = 0.08
|
const LABEL_LIFT = 0.08
|
||||||
const BAR_THICKNESS = 0.012
|
const BAR_THICKNESS = 0.012
|
||||||
const LINE_OPACITY = 0.95
|
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)
|
const BAR_AXIS = new THREE.Vector3(0, 1, 0)
|
||||||
|
|
||||||
@@ -39,6 +43,18 @@ type MeasurementGuide = {
|
|||||||
extEndStart: Vec3
|
extEndStart: Vec3
|
||||||
extEndEnd: Vec3
|
extEndEnd: Vec3
|
||||||
labelPosition: 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') {
|
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||||
@@ -57,28 +73,28 @@ export function WallMeasurementLabel() {
|
|||||||
const nodes = useScene((state) => state.nodes)
|
const nodes = useScene((state) => state.nodes)
|
||||||
|
|
||||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||||
const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
|
const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
|
||||||
const wall = selectedNode?.type === 'wall' ? selectedNode : null
|
const measurableNode =
|
||||||
|
selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
|
||||||
|
|
||||||
const [wallObjectState, setWallObjectState] = useState<{
|
const [objectState, setObjectState] = useState<{
|
||||||
id: WallNode['id']
|
id: AnyNodeId
|
||||||
object: THREE.Object3D
|
object: THREE.Object3D
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const wallObject =
|
const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null
|
||||||
selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
|
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (!selectedId || wallObject) return
|
if (!selectedId || selectedObject) return
|
||||||
|
|
||||||
const nextWallObject = sceneRegistry.nodes.get(selectedId)
|
const nextObject = sceneRegistry.nodes.get(selectedId)
|
||||||
if (nextWallObject) {
|
if (nextObject) {
|
||||||
setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject })
|
setObjectState({ id: selectedId as AnyNodeId, object: nextObject })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!(wall && wallObject)) return null
|
if (!(measurableNode && selectedObject)) return null
|
||||||
|
|
||||||
return createPortal(<WallMeasurementAnnotation wall={wall} />, wallObject)
|
return createPortal(<SelectedMeasurementAnnotation node={measurableNode} />, selectedObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLevelWalls(
|
function getLevelWalls(
|
||||||
@@ -97,6 +113,114 @@ function getLevelWalls(
|
|||||||
.filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
|
.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(
|
function getWallMiddlePoints(
|
||||||
wall: WallNode,
|
wall: WallNode,
|
||||||
miterData: WallMiterData,
|
miterData: WallMiterData,
|
||||||
@@ -136,7 +260,10 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
|
|||||||
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
|
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'>) {
|
function getWallExteriorOffsetSign(
|
||||||
|
wall: Pick<WallNode, 'start' | 'end' | 'frontSide' | 'backSide'>,
|
||||||
|
levelWalls: WallNode[],
|
||||||
|
) {
|
||||||
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
|
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -145,10 +272,31 @@ function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
return 1
|
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 1
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
return fromCenter.x * normal.x + fromCenter.y * normal.y >= 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)
|
const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
|
||||||
if (!boundaryPoints) return null
|
if (!boundaryPoints) return null
|
||||||
|
|
||||||
@@ -156,7 +304,7 @@ function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData):
|
|||||||
const sidePointCount = 25
|
const sidePointCount = 25
|
||||||
if (surface.length < sidePointCount * 2) return null
|
if (surface.length < sidePointCount * 2) return null
|
||||||
|
|
||||||
const offsetSign = getWallExteriorOffsetSign(wall)
|
const offsetSign = getWallExteriorOffsetSign(wall, levelWalls)
|
||||||
if (offsetSign >= 0) {
|
if (offsetSign >= 0) {
|
||||||
return surface.slice(sidePointCount).reverse()
|
return surface.slice(sidePointCount).reverse()
|
||||||
}
|
}
|
||||||
@@ -170,14 +318,16 @@ function buildMeasurementGuide(
|
|||||||
): MeasurementGuide | null {
|
): MeasurementGuide | null {
|
||||||
const levelWalls = getLevelWalls(wall, nodes)
|
const levelWalls = getLevelWalls(wall, nodes)
|
||||||
const miterData = calculateLevelMiters(levelWalls)
|
const miterData = calculateLevelMiters(levelWalls)
|
||||||
const middlePoints = getWallMiddlePoints(wall, miterData)
|
const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls)
|
||||||
if (!middlePoints) return null
|
const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData)
|
||||||
|
const measurementPoints = measurementLine ?? fallbackMiddlePoints
|
||||||
|
if (!measurementPoints) return null
|
||||||
|
|
||||||
const height = wall.height ?? DEFAULT_WALL_HEIGHT
|
const height = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||||
const startLocal = worldPointToWallLocal(wall, middlePoints.start)
|
const startLocal = worldPointToWallLocal(wall, measurementPoints.start)
|
||||||
const endLocal = worldPointToWallLocal(wall, middlePoints.end)
|
const endLocal = worldPointToWallLocal(wall, measurementPoints.end)
|
||||||
const curvedMeasurementPath = isCurvedWall(wall)
|
const curvedMeasurementPath = isCurvedWall(wall)
|
||||||
? getCurvedWallMeasurementPath(wall, miterData)
|
? getCurvedWallMeasurementPath(wall, miterData, levelWalls)
|
||||||
: null
|
: null
|
||||||
const guidePath: Vec3[] = curvedMeasurementPath
|
const guidePath: Vec3[] = curvedMeasurementPath
|
||||||
? curvedMeasurementPath.map((point) => {
|
? curvedMeasurementPath.map((point) => {
|
||||||
@@ -224,6 +374,38 @@ function buildMeasurementGuide(
|
|||||||
guideStart[1],
|
guideStart[1],
|
||||||
(guideStart[2] + guideEnd[2]) / 2,
|
(guideStart[2] + guideEnd[2]) / 2,
|
||||||
] as Vec3)
|
] 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 {
|
return {
|
||||||
guidePath,
|
guidePath,
|
||||||
@@ -236,6 +418,37 @@ function buildMeasurementGuide(
|
|||||||
extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
|
extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
|
||||||
extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
|
extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
|
||||||
labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[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 (
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
position={position}
|
||||||
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
|
zIndexRange={[20, 0]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="whitespace-nowrap font-bold font-mono text-[15px]"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) {
|
||||||
|
if (node.type === 'wall') {
|
||||||
|
return <WallMeasurementAnnotation wall={node} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ItemHeightMeasurementAnnotation item={node} />
|
||||||
|
}
|
||||||
|
|
||||||
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
||||||
const nodes = useScene((state) => state.nodes)
|
const nodes = useScene((state) => state.nodes)
|
||||||
const theme = useViewer((state) => state.theme)
|
const theme = useViewer((state) => state.theme)
|
||||||
@@ -316,6 +568,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|||||||
return total
|
return total
|
||||||
}, [guide, wall])
|
}, [guide, wall])
|
||||||
const label = formatMeasurement(length, unit)
|
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
|
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
|
||||||
|
|
||||||
@@ -324,23 +577,50 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|||||||
<MeasurementPath color={color} path={guide.guidePath} />
|
<MeasurementPath color={color} path={guide.guidePath} />
|
||||||
<MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
|
<MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
|
||||||
<MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
|
<MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
|
||||||
|
<MeasurementBar color={color} end={guide.heightEnd} start={guide.heightStart} />
|
||||||
|
<MeasurementBar
|
||||||
|
color={color}
|
||||||
|
end={guide.heightBottomTickEnd}
|
||||||
|
start={guide.heightBottomTickStart}
|
||||||
|
/>
|
||||||
|
<MeasurementBar color={color} end={guide.heightTopTickEnd} start={guide.heightTopTickStart} />
|
||||||
|
|
||||||
<Html
|
<MeasurementLabel
|
||||||
center
|
color={color}
|
||||||
|
label={label}
|
||||||
position={guide.labelPosition}
|
position={guide.labelPosition}
|
||||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
shadowColor={shadowColor}
|
||||||
zIndexRange={[20, 0]}
|
/>
|
||||||
>
|
<MeasurementLabel
|
||||||
<div
|
color={color}
|
||||||
className="whitespace-nowrap font-bold font-mono text-[15px]"
|
label={heightLabel}
|
||||||
style={{
|
position={guide.heightLabelPosition}
|
||||||
color,
|
shadowColor={shadowColor}
|
||||||
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
|
/>
|
||||||
}}
|
</group>
|
||||||
>
|
)
|
||||||
{label}
|
}
|
||||||
</div>
|
|
||||||
</Html>
|
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 (
|
||||||
|
<group>
|
||||||
|
<MeasurementBar color={color} end={measurement.guide.end} start={measurement.guide.start} />
|
||||||
|
<MeasurementLabel
|
||||||
|
color={color}
|
||||||
|
label={`H ${formatMeasurement(measurement.height, unit)}`}
|
||||||
|
position={measurement.guide.labelPosition}
|
||||||
|
shadowColor={shadowColor}
|
||||||
|
/>
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
<img
|
||||||
|
alt="Column"
|
||||||
|
src="/icons/column.png"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||||
|
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
|
||||||
|
|
||||||
|
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
|
||||||
|
const { label, ...preset } = COLUMN_PRESETS[presetId]
|
||||||
|
return ColumnNode.parse({
|
||||||
|
name: label,
|
||||||
|
position,
|
||||||
|
rotation: 0,
|
||||||
|
...preset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnToolProps = {
|
||||||
|
currentLevelId: LevelNode['id'] | null
|
||||||
|
onPlaced?: (nodeId: ColumnNodeType['id']) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced }) => {
|
||||||
|
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
|
||||||
|
const cursorRef = useRef<Group>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentLevelId) return
|
||||||
|
|
||||||
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
const nextPosition: [number, number, number] = [
|
||||||
|
roundToHalf(event.localPosition[0]),
|
||||||
|
0,
|
||||||
|
roundToHalf(event.localPosition[2]),
|
||||||
|
]
|
||||||
|
setCursorPosition(nextPosition)
|
||||||
|
cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
const position: [number, number, number] = [
|
||||||
|
roundToHalf(event.localPosition[0]),
|
||||||
|
0,
|
||||||
|
roundToHalf(event.localPosition[2]),
|
||||||
|
]
|
||||||
|
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
|
||||||
|
useScene.getState().createNode(column, currentLevelId)
|
||||||
|
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 (
|
||||||
|
<CursorSphere
|
||||||
|
color="#a78bfa"
|
||||||
|
height={2.8}
|
||||||
|
ref={cursorRef}
|
||||||
|
showTooltip
|
||||||
|
tooltipContent={COLUMN_ICON}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import '../../../three-types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
ColumnNode,
|
||||||
|
type ColumnNode as ColumnNodeType,
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
sceneRegistry,
|
||||||
|
useLiveTransforms,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
|
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||||
|
|
||||||
|
export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
|
||||||
|
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||||
|
|
||||||
|
const exitMoveMode = useCallback(() => {
|
||||||
|
useEditor.getState().setMovingNode(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
let committed = false
|
||||||
|
|
||||||
|
const applyPreview = (position: [number, number, number]) => {
|
||||||
|
setPreviewPosition(position)
|
||||||
|
useLiveTransforms.getState().set(node.id, {
|
||||||
|
position,
|
||||||
|
rotation: node.rotation,
|
||||||
|
})
|
||||||
|
sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
const position: [number, number, number] = [
|
||||||
|
roundToHalf(event.localPosition[0]),
|
||||||
|
0,
|
||||||
|
roundToHalf(event.localPosition[2]),
|
||||||
|
]
|
||||||
|
const nodeId = (node as { id?: ColumnNodeType['id'] }).id
|
||||||
|
|
||||||
|
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||||
|
committed = true
|
||||||
|
useLiveTransforms.getState().clear(nodeId)
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
useScene.getState().updateNode(nodeId, { position })
|
||||||
|
} else if (node.parentId) {
|
||||||
|
const column = ColumnNode.parse({
|
||||||
|
...node,
|
||||||
|
id: undefined,
|
||||||
|
metadata: {},
|
||||||
|
position,
|
||||||
|
})
|
||||||
|
committed = true
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
useScene.getState().createNode(column, node.parentId as AnyNodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
exitMoveMode()
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
sceneRegistry.nodes
|
||||||
|
.get(node.id)
|
||||||
|
?.position.set(node.position[0], node.position[1], node.position[2])
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
markToolCancelConsumed()
|
||||||
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
|
emitter.on('grid:click', onGridClick)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
|
emitter.off('grid:click', onGridClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
if (!committed) {
|
||||||
|
sceneRegistry.nodes
|
||||||
|
.get(node.id)
|
||||||
|
?.position.set(node.position[0], node.position[1], node.position[2])
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [exitMoveMode, node])
|
||||||
|
|
||||||
|
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
|
||||||
|
}
|
||||||
@@ -1,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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import {
|
import {
|
||||||
|
findWallSnapTarget,
|
||||||
getWallAngleSnapStep,
|
getWallAngleSnapStep,
|
||||||
getWallGridStep,
|
getWallGridStep,
|
||||||
type WallPlanPoint,
|
|
||||||
findWallSnapTarget,
|
|
||||||
isWallLongEnough,
|
isWallLongEnough,
|
||||||
snapPointTo45Degrees,
|
snapPointTo45Degrees,
|
||||||
snapPointToGrid,
|
snapPointToGrid,
|
||||||
|
type WallPlanPoint,
|
||||||
} from '../wall/wall-drafting'
|
} from '../wall/wall-drafting'
|
||||||
|
|
||||||
export type FencePlanPoint = WallPlanPoint
|
export type FencePlanPoint = WallPlanPoint
|
||||||
|
|||||||
@@ -7,19 +7,129 @@ import {
|
|||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
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 { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
import {
|
||||||
|
formatAngleRadians,
|
||||||
|
getAngleToSegmentReference,
|
||||||
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
} from '../shared/segment-angle'
|
||||||
import {
|
import {
|
||||||
createFenceOnCurrentLevel,
|
createFenceOnCurrentLevel,
|
||||||
snapFenceDraftPoint,
|
|
||||||
type FencePlanPoint,
|
type FencePlanPoint,
|
||||||
|
snapFenceDraftPoint,
|
||||||
} from './fence-drafting'
|
} from './fence-drafting'
|
||||||
|
|
||||||
const FENCE_PREVIEW_HEIGHT = 1.8
|
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 updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
||||||
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
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 = () => {
|
export const FenceTool: React.FC = () => {
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
const cursorRef = useRef<Group>(null)
|
const cursorRef = useRef<Group>(null)
|
||||||
const previewRef = useRef<Mesh>(null!)
|
const previewRef = useRef<Mesh>(null!)
|
||||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const buildingState = useRef(0)
|
const buildingState = useRef(0)
|
||||||
const shiftPressed = useRef(false)
|
const shiftPressed = useRef(false)
|
||||||
|
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let previousFenceEnd: [number, number] | null = null
|
let previousFenceEnd: [number, number] | null = null
|
||||||
@@ -107,9 +219,18 @@ export const FenceTool: React.FC = () => {
|
|||||||
previousFenceEnd = currentFenceEnd
|
previousFenceEnd = currentFenceEnd
|
||||||
|
|
||||||
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
|
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
|
||||||
|
setDraftMeasurement(
|
||||||
|
getDraftMeasurementState(
|
||||||
|
[startingPoint.current.x, startingPoint.current.z],
|
||||||
|
snappedLocal,
|
||||||
|
getReferenceSegments(walls, fences),
|
||||||
|
unit,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
|
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
|
||||||
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
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)
|
endingPoint.current.copy(startingPoint.current)
|
||||||
buildingState.current = 1
|
buildingState.current = 1
|
||||||
previewRef.current.visible = true
|
previewRef.current.visible = true
|
||||||
|
setDraftMeasurement(null)
|
||||||
} else {
|
} else {
|
||||||
const snappedEnd = snapFenceDraftPoint({
|
const snappedEnd = snapFenceDraftPoint({
|
||||||
point: localClick,
|
point: localClick,
|
||||||
@@ -137,6 +259,7 @@ export const FenceTool: React.FC = () => {
|
|||||||
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
||||||
previewRef.current.visible = false
|
previewRef.current.visible = false
|
||||||
buildingState.current = 0
|
buildingState.current = 0
|
||||||
|
setDraftMeasurement(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +276,7 @@ export const FenceTool: React.FC = () => {
|
|||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
buildingState.current = 0
|
buildingState.current = 0
|
||||||
previewRef.current.visible = false
|
previewRef.current.visible = false
|
||||||
|
setDraftMeasurement(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +293,7 @@ export const FenceTool: React.FC = () => {
|
|||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
window.removeEventListener('keyup', onKeyUp)
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [unit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -185,6 +309,38 @@ export const FenceTool: React.FC = () => {
|
|||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
|
{draftMeasurement && (
|
||||||
|
<>
|
||||||
|
<DraftMeasurementLabel
|
||||||
|
label={draftMeasurement.lengthLabel}
|
||||||
|
position={draftMeasurement.lengthPosition}
|
||||||
|
/>
|
||||||
|
{draftMeasurement.angleLabels.map((angleLabel) => (
|
||||||
|
<DraftMeasurementLabel
|
||||||
|
key={angleLabel.id}
|
||||||
|
label={angleLabel.label}
|
||||||
|
position={angleLabel.position}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DraftMeasurementLabel({
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
position: [number, number, number]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||||
|
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,32 +2,113 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type FenceNode,
|
|
||||||
type WallNode,
|
|
||||||
emitter,
|
emitter,
|
||||||
|
type FenceNode,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
pauseSceneHistory,
|
pauseSceneHistory,
|
||||||
resumeSceneHistory,
|
resumeSceneHistory,
|
||||||
useScene,
|
useScene,
|
||||||
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { Html } from '@react-three/drei'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
|
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
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 { isWallLongEnough } from '../wall/wall-drafting'
|
||||||
|
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
|
||||||
|
|
||||||
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
|
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
|
||||||
return a[0] === b[0] && a[1] === b[1]
|
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 = {
|
type LinkedFenceSnapshot = {
|
||||||
id: FenceNode['id']
|
id: FenceNode['id']
|
||||||
start: FencePlanPoint
|
start: FencePlanPoint
|
||||||
end: FencePlanPoint
|
end: FencePlanPoint
|
||||||
|
curveOffset?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLinkedFenceSnapshots(args: {
|
function getLinkedFenceSnapshots(args: {
|
||||||
@@ -62,6 +143,7 @@ function getLinkedFenceSnapshots(args: {
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
start: [...node.start] as FencePlanPoint,
|
start: [...node.start] as FencePlanPoint,
|
||||||
end: [...node.end] as FencePlanPoint,
|
end: [...node.end] as FencePlanPoint,
|
||||||
|
curveOffset: node.curveOffset,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +159,7 @@ function getLinkedFenceUpdates(
|
|||||||
) {
|
) {
|
||||||
return linkedFences.map((fence) => ({
|
return linkedFences.map((fence) => ({
|
||||||
id: fence.id,
|
id: fence.id,
|
||||||
|
curveOffset: fence.curveOffset,
|
||||||
start: samePoint(fence.start, originalStart)
|
start: samePoint(fence.start, originalStart)
|
||||||
? nextStart
|
? nextStart
|
||||||
: samePoint(fence.start, originalEnd)
|
: 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 previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
|
||||||
|
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
|
||||||
|
|
||||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||||
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
|
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 applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
|
||||||
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
||||||
const nextEnd = target.endpoint === 'end' ? 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 }
|
previewRef.current = { start: nextStart, end: nextEnd }
|
||||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||||
applyNodePreview([
|
setAngleLabel(
|
||||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
getEndpointAngleLabel({
|
||||||
...(detachLinkedFences
|
preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
|
||||||
? []
|
segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
|
||||||
: getLinkedFenceUpdates(
|
nodeId,
|
||||||
linkedOriginalsRef.current,
|
}),
|
||||||
originalStart,
|
)
|
||||||
originalEnd,
|
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
|
||||||
nextStart,
|
|
||||||
nextEnd,
|
|
||||||
)),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreOriginal = () => {
|
const restoreOriginal = (clearAngleLabel = true) => {
|
||||||
applyNodePreview([
|
applyNodePreview([
|
||||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||||
...linkedOriginalsRef.current,
|
...linkedOriginalsRef.current,
|
||||||
])
|
])
|
||||||
|
if (clearAngleLabel) {
|
||||||
|
setAngleLabel(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
@@ -240,6 +332,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
setAngleLabel(null)
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
@@ -248,6 +341,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|||||||
restoreOriginal()
|
restoreOriginal()
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
resumeSceneHistory(useScene)
|
resumeSceneHistory(useScene)
|
||||||
|
setAngleLabel(null)
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
@@ -290,7 +384,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!wasCommitted) {
|
if (!wasCommitted) {
|
||||||
restoreOriginal()
|
restoreOriginal(false)
|
||||||
}
|
}
|
||||||
resumeSceneHistory(useScene)
|
resumeSceneHistory(useScene)
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
@@ -322,6 +416,23 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
|
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EndpointAngleLabel({
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
position: [number, number, number]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||||
|
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
BuildingNode,
|
BuildingNode,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
|
ColumnNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
@@ -18,6 +19,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { MoveBuildingContent } from '../building/move-building-tool'
|
import { MoveBuildingContent } from '../building/move-building-tool'
|
||||||
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
||||||
|
import { MoveColumnTool } from '../column/move-column-tool'
|
||||||
import { MoveDoorTool } from '../door/move-door-tool'
|
import { MoveDoorTool } from '../door/move-door-tool'
|
||||||
import { MoveFenceTool } from '../fence/move-fence-tool'
|
import { MoveFenceTool } from '../fence/move-fence-tool'
|
||||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||||
@@ -100,6 +102,7 @@ export const MoveTool: React.FC<{
|
|||||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||||
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
||||||
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
||||||
|
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
||||||
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
||||||
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
||||||
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||||
|
|||||||
@@ -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<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
|||||||
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
||||||
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
||||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||||
|
import { ColumnTool } from './column/column-tool'
|
||||||
import { DoorTool } from './door/door-tool'
|
import { DoorTool } from './door/door-tool'
|
||||||
import { CurveFenceTool } from './fence/curve-fence-tool'
|
import { CurveFenceTool } from './fence/curve-fence-tool'
|
||||||
import { FenceTool } from './fence/fence-tool'
|
import { FenceTool } from './fence/fence-tool'
|
||||||
@@ -126,7 +127,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
const showBuildTool = mode === 'build' && tool !== null
|
const showBuildTool = mode === 'build' && tool !== null
|
||||||
|
|
||||||
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
|
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
|
||||||
const handleSpawnSelected = (nodeId: `spawn_${string}`) => {
|
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
|
||||||
setSelection({ selectedIds: [nodeId] })
|
setSelection({ selectedIds: [nodeId] })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||||
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
||||||
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handleSpawnSelected} />}
|
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
|
||||||
|
|
||||||
{/* Building-local group: all other tools are relative to the selected 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
|
Cursor visuals set positions in building-local space; this group applies the
|
||||||
@@ -159,12 +160,15 @@ export const ToolManager: React.FC = () => {
|
|||||||
{curvingWall && <CurveWallTool node={curvingWall} />}
|
{curvingWall && <CurveWallTool node={curvingWall} />}
|
||||||
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
||||||
{movingNode && movingNode.type !== 'building' && (
|
{movingNode && movingNode.type !== 'building' && (
|
||||||
<MoveTool onSpawnMoved={handleSpawnSelected} />
|
<MoveTool onSpawnMoved={handlePlacedNodeSelected} />
|
||||||
)}
|
)}
|
||||||
{!movingNode && showBuildTool && tool === 'spawn' && (
|
{!movingNode && showBuildTool && tool === 'spawn' && (
|
||||||
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handleSpawnSelected} />
|
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
||||||
)}
|
)}
|
||||||
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
{!movingNode && showBuildTool && tool === 'column' && (
|
||||||
|
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
||||||
|
)}
|
||||||
|
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
|
||||||
</group>
|
</group>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,27 +9,87 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { Html } from '@react-three/drei'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
|
import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
import {
|
import {
|
||||||
isWallLongEnough,
|
formatAngleRadians,
|
||||||
snapWallDraftPoint,
|
getAngleToSegmentReference,
|
||||||
type WallPlanPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
} from './wall-drafting'
|
} from '../shared/segment-angle'
|
||||||
|
import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
||||||
|
|
||||||
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
|
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
|
||||||
return a[0] === b[0] && a[1] === b[1]
|
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 = {
|
type LinkedWallSnapshot = {
|
||||||
id: WallNode['id']
|
id: WallNode['id']
|
||||||
start: WallPlanPoint
|
start: WallPlanPoint
|
||||||
end: WallPlanPoint
|
end: WallPlanPoint
|
||||||
|
curveOffset?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLinkedWallSnapshots(args: {
|
function getLinkedWallSnapshots(args: {
|
||||||
@@ -64,6 +124,7 @@ function getLinkedWallSnapshots(args: {
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
start: [...node.start] as WallPlanPoint,
|
start: [...node.start] as WallPlanPoint,
|
||||||
end: [...node.end] as WallPlanPoint,
|
end: [...node.end] as WallPlanPoint,
|
||||||
|
curveOffset: node.curveOffset,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +140,7 @@ function getLinkedWallUpdates(
|
|||||||
) {
|
) {
|
||||||
return linkedWalls.map((wall) => ({
|
return linkedWalls.map((wall) => ({
|
||||||
id: wall.id,
|
id: wall.id,
|
||||||
|
curveOffset: wall.curveOffset,
|
||||||
start: samePoint(wall.start, originalStart)
|
start: samePoint(wall.start, originalStart)
|
||||||
? nextStart
|
? nextStart
|
||||||
: samePoint(wall.start, originalEnd)
|
: 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 previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
|
||||||
|
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
|
||||||
|
|
||||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||||
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
|
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 applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
|
||||||
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
||||||
const nextEnd = target.endpoint === 'end' ? 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 }
|
previewRef.current = { start: nextStart, end: nextEnd }
|
||||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||||
applyNodePreview([
|
setAngleLabel(
|
||||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
getEndpointAngleLabel({
|
||||||
...(detachLinkedWalls
|
preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
|
||||||
? []
|
walls: [
|
||||||
: getLinkedWallUpdates(
|
...levelWalls.map((wall) => ({
|
||||||
linkedOriginalsRef.current,
|
id: wall.id,
|
||||||
originalStart,
|
start: wall.start,
|
||||||
originalEnd,
|
end: wall.end,
|
||||||
nextStart,
|
curveOffset: wall.curveOffset,
|
||||||
nextEnd,
|
})),
|
||||||
)),
|
...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])
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||||
|
...linkedOriginalsRef.current,
|
||||||
|
])
|
||||||
|
if (clearAngleLabel) {
|
||||||
|
setAngleLabel(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
@@ -235,6 +317,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
setAngleLabel(null)
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
@@ -243,6 +326,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|||||||
restoreOriginal()
|
restoreOriginal()
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
resumeSceneHistory(useScene)
|
resumeSceneHistory(useScene)
|
||||||
|
setAngleLabel(null)
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
@@ -285,7 +369,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!wasCommitted) {
|
if (!wasCommitted) {
|
||||||
restoreOriginal()
|
restoreOriginal(false)
|
||||||
}
|
}
|
||||||
resumeSceneHistory(useScene)
|
resumeSceneHistory(useScene)
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
@@ -317,6 +401,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
|
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EndpointAngleLabel({
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
position: [number, number, number]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||||
|
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
getScaledDimensions,
|
getScaledDimensions,
|
||||||
|
getWallCurveFrameAt,
|
||||||
|
getWallCurveLength,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
|
isCurvedWall,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
WallNode as WallSchema,
|
WallNode as WallSchema,
|
||||||
@@ -62,10 +65,10 @@ export function snapPointTo45Degrees(
|
|||||||
const snappedAngle = Math.round(angle / angleStep) * angleStep
|
const snappedAngle = Math.round(angle / angleStep) * angleStep
|
||||||
const distance = Math.sqrt(dx * dx + dz * dz)
|
const distance = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
|
||||||
return snapPointToGrid([
|
return snapPointToGrid(
|
||||||
start[0] + Math.cos(snappedAngle) * distance,
|
[start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
|
||||||
start[1] + Math.sin(snappedAngle) * distance,
|
step,
|
||||||
], step)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWallAngleSnapStep(step = getWallGridStep()): number {
|
export function getWallAngleSnapStep(step = getWallGridStep()): number {
|
||||||
@@ -336,11 +339,17 @@ export function findWallSnapTarget(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates: Array<WallPlanPoint | null> = [
|
const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
|
||||||
wall.start,
|
|
||||||
wall.end,
|
if (isCurvedWall(wall)) {
|
||||||
projectPointOntoWall(point, 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) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,14 +1,100 @@
|
|||||||
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
|
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
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 { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
import {
|
||||||
|
formatAngleRadians,
|
||||||
|
getAngleToSegmentReference,
|
||||||
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
} from '../shared/segment-angle'
|
||||||
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
||||||
|
|
||||||
const WALL_HEIGHT = 2.5
|
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
|
* 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 = () => {
|
export const WallTool: React.FC = () => {
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
const cursorRef = useRef<Group>(null)
|
const cursorRef = useRef<Group>(null)
|
||||||
const wallPreviewRef = useRef<Mesh>(null!)
|
const wallPreviewRef = useRef<Mesh>(null!)
|
||||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const buildingState = useRef(0)
|
const buildingState = useRef(0)
|
||||||
const shiftPressed = useRef(false)
|
const shiftPressed = useRef(false)
|
||||||
|
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let gridPosition: WallPlanPoint = [0, 0]
|
let gridPosition: WallPlanPoint = [0, 0]
|
||||||
@@ -109,9 +197,18 @@ export const WallTool: React.FC = () => {
|
|||||||
previousWallEnd = currentWallEnd
|
previousWallEnd = currentWallEnd
|
||||||
|
|
||||||
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
||||||
|
setDraftMeasurement(
|
||||||
|
getDraftMeasurementState(
|
||||||
|
[startingPoint.current.x, startingPoint.current.z],
|
||||||
|
snappedLocal,
|
||||||
|
walls,
|
||||||
|
unit,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// Not drawing a wall yet, show the snapped anchor point.
|
// Not drawing a wall yet, show the snapped anchor point.
|
||||||
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
|
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)
|
endingPoint.current.copy(startingPoint.current)
|
||||||
buildingState.current = 1
|
buildingState.current = 1
|
||||||
wallPreviewRef.current.visible = true
|
wallPreviewRef.current.visible = true
|
||||||
|
setDraftMeasurement(null)
|
||||||
} else if (buildingState.current === 1) {
|
} else if (buildingState.current === 1) {
|
||||||
const snappedEnd = snapWallDraftPoint({
|
const snappedEnd = snapWallDraftPoint({
|
||||||
point: localClick,
|
point: localClick,
|
||||||
@@ -140,6 +238,7 @@ export const WallTool: React.FC = () => {
|
|||||||
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
||||||
wallPreviewRef.current.visible = false
|
wallPreviewRef.current.visible = false
|
||||||
buildingState.current = 0
|
buildingState.current = 0
|
||||||
|
setDraftMeasurement(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +259,7 @@ export const WallTool: React.FC = () => {
|
|||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
buildingState.current = 0
|
buildingState.current = 0
|
||||||
wallPreviewRef.current.visible = false
|
wallPreviewRef.current.visible = false
|
||||||
|
setDraftMeasurement(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +276,7 @@ export const WallTool: React.FC = () => {
|
|||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
window.removeEventListener('keyup', onKeyUp)
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [unit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -195,6 +295,38 @@ export const WallTool: React.FC = () => {
|
|||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
|
{draftMeasurement && (
|
||||||
|
<>
|
||||||
|
<DraftMeasurementLabel
|
||||||
|
label={draftMeasurement.lengthLabel}
|
||||||
|
position={draftMeasurement.lengthPosition}
|
||||||
|
/>
|
||||||
|
{draftMeasurement.angleLabels.map((angleLabel) => (
|
||||||
|
<DraftMeasurementLabel
|
||||||
|
key={angleLabel.id}
|
||||||
|
label={angleLabel.label}
|
||||||
|
position={angleLabel.position}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DraftMeasurementLabel({
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
position: [number, number, number]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||||
|
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const tools: ToolConfig[] = [
|
|||||||
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
|
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
|
||||||
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
||||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
{ 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: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
||||||
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
||||||
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||||
|
|||||||
@@ -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<ColumnNode> {
|
||||||
|
const { label, ...preset } = COLUMN_PRESETS[presetId]
|
||||||
|
return {
|
||||||
|
name: label,
|
||||||
|
...preset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function proportionUpdates(
|
||||||
|
node: ColumnNode,
|
||||||
|
presetId: ColumnProportionPresetId,
|
||||||
|
): Partial<ColumnNode> {
|
||||||
|
const preset = COLUMN_PROPORTION_PRESETS[presetId]
|
||||||
|
const depth =
|
||||||
|
node.crossSection === 'rectangular'
|
||||||
|
? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6)
|
||||||
|
: preset.width
|
||||||
|
const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18)
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: preset.height,
|
||||||
|
width: preset.width,
|
||||||
|
depth,
|
||||||
|
radius: preset.width / 2,
|
||||||
|
baseHeight: preset.baseHeight,
|
||||||
|
capitalHeight: preset.capitalHeight,
|
||||||
|
baseWidthScale: preset.baseWidthScale,
|
||||||
|
baseDepthScale: preset.baseWidthScale,
|
||||||
|
capitalWidthScale: preset.capitalWidthScale,
|
||||||
|
capitalDepthScale: preset.capitalWidthScale,
|
||||||
|
edgeSoftness: preset.edgeSoftness,
|
||||||
|
shaftCornerRadius,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial<ColumnNode> {
|
||||||
|
if (shaftProfile === 'tapered') {
|
||||||
|
return {
|
||||||
|
shaftProfile,
|
||||||
|
shaftTaper: 0.14,
|
||||||
|
shaftBulge: 0,
|
||||||
|
shaftStartScale: 0.82,
|
||||||
|
shaftEndScale: 0.72,
|
||||||
|
shaftSegmentCount: 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shaftProfile === 'bulged') {
|
||||||
|
return {
|
||||||
|
shaftProfile,
|
||||||
|
shaftTaper: 0,
|
||||||
|
shaftBulge: 0.12,
|
||||||
|
shaftStartScale: 0.68,
|
||||||
|
shaftEndScale: 0.68,
|
||||||
|
shaftSegmentCount: 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shaftProfile === 'hourglass') {
|
||||||
|
return {
|
||||||
|
shaftProfile,
|
||||||
|
shaftTaper: 0,
|
||||||
|
shaftBulge: 0.12,
|
||||||
|
shaftStartScale: 0.84,
|
||||||
|
shaftEndScale: 0.84,
|
||||||
|
shaftSegmentCount: 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shaftProfile,
|
||||||
|
shaftTaper: 0,
|
||||||
|
shaftBulge: 0,
|
||||||
|
shaftStartScale: 0.72,
|
||||||
|
shaftEndScale: 0.72,
|
||||||
|
shaftSegmentCount: 1,
|
||||||
|
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<ColumnNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!selectedId) return
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
deleteNode(selectedId as AnyNode['id'])
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [deleteNode, selectedId, setSelection])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setMovingNode(node)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
|
||||||
|
const shaftProfile = node.shaftProfile ?? 'straight'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelWrapper icon="/icons/column.png" onClose={handleClose} title={node.name || 'Column'} width={300}>
|
||||||
|
<PanelSection title="Preset">
|
||||||
|
<select
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!event.target.value) return
|
||||||
|
handleUpdate(presetUpdates(event.target.value as ColumnPresetId))
|
||||||
|
}}
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
<option value="">Apply preset...</option>
|
||||||
|
{COLUMN_PRESET_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Shape">
|
||||||
|
<select
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
onChange={(event) => handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })}
|
||||||
|
value={node.crossSection}
|
||||||
|
>
|
||||||
|
<option value="round">Round</option>
|
||||||
|
<option value="square">Square</option>
|
||||||
|
<option value="rectangular">Rectangular</option>
|
||||||
|
</select>
|
||||||
|
<SliderControl
|
||||||
|
label="Edge Softness"
|
||||||
|
max={0.12}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => handleUpdate({ edgeSoftness: value })}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={node.edgeSoftness ?? 0.025}
|
||||||
|
/>
|
||||||
|
{(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
|
||||||
|
<SliderControl
|
||||||
|
label="Shaft Corner Radius"
|
||||||
|
max={0.3}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={node.shaftCornerRadius ?? 0.035}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Dimensions">
|
||||||
|
<select
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!event.target.value) return
|
||||||
|
handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
|
||||||
|
}}
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
<option value="">Apply proportion...</option>
|
||||||
|
{COLUMN_PROPORTION_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<SliderControl
|
||||||
|
label="Height"
|
||||||
|
max={6}
|
||||||
|
min={0.8}
|
||||||
|
onChange={(value) => handleUpdate({ height: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={node.height}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Width"
|
||||||
|
max={1.6}
|
||||||
|
min={0.12}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleUpdate({
|
||||||
|
width: value,
|
||||||
|
radius: value / 2,
|
||||||
|
...(node.crossSection === 'rectangular' ? {} : { depth: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
unit="m"
|
||||||
|
value={node.width}
|
||||||
|
/>
|
||||||
|
{node.crossSection === 'rectangular' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Depth"
|
||||||
|
max={1.6}
|
||||||
|
min={0.12}
|
||||||
|
onChange={(value) => handleUpdate({ depth: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
unit="m"
|
||||||
|
value={node.depth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Shaft">
|
||||||
|
<select
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
onChange={(event) => handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))}
|
||||||
|
value={shaftProfile}
|
||||||
|
>
|
||||||
|
<option value="straight">Straight</option>
|
||||||
|
<option value="tapered">Tapered</option>
|
||||||
|
<option value="bulged">Bulged</option>
|
||||||
|
<option value="hourglass">Hourglass</option>
|
||||||
|
</select>
|
||||||
|
{shaftProfile === 'straight' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Shaft Width"
|
||||||
|
max={1.2}
|
||||||
|
min={0.3}
|
||||||
|
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
value={node.shaftStartScale ?? 0.72}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{shaftProfile === 'tapered' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="Bottom Width"
|
||||||
|
max={1.2}
|
||||||
|
min={0.3}
|
||||||
|
onChange={(value) => handleUpdate({ shaftStartScale: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
value={node.shaftStartScale ?? 0.82}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Top Width"
|
||||||
|
max={1.2}
|
||||||
|
min={0.3}
|
||||||
|
onChange={(value) => handleUpdate({ shaftEndScale: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
value={node.shaftEndScale ?? 0.72}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Taper"
|
||||||
|
max={0.45}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => handleUpdate({ shaftTaper: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
value={node.shaftTaper ?? 0.14}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{shaftProfile === 'bulged' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="End Width"
|
||||||
|
max={1.2}
|
||||||
|
min={0.3}
|
||||||
|
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
value={node.shaftStartScale ?? 0.68}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Bulge"
|
||||||
|
max={0.35}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
value={node.shaftBulge ?? 0.12}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{shaftProfile === 'hourglass' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="End Width"
|
||||||
|
max={1.2}
|
||||||
|
min={0.3}
|
||||||
|
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
value={node.shaftStartScale ?? 0.84}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Waist"
|
||||||
|
max={0.35}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
value={node.shaftBulge ?? 0.12}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SliderControl
|
||||||
|
label="Segment Twist"
|
||||||
|
max={90}
|
||||||
|
min={-90}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleUpdate({
|
||||||
|
shaftTwistStep: value,
|
||||||
|
...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
|
||||||
|
? { shaftSegmentCount: 12 }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={0}
|
||||||
|
step={5}
|
||||||
|
unit="°"
|
||||||
|
value={node.shaftTwistStep ?? 0}
|
||||||
|
/>
|
||||||
|
{Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
|
||||||
|
<SliderControl
|
||||||
|
label="Twist Segments"
|
||||||
|
max={48}
|
||||||
|
min={4}
|
||||||
|
onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
value={node.shaftSegmentCount ?? 12}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SliderControl
|
||||||
|
label="Ring Pairs"
|
||||||
|
max={4}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleUpdate({
|
||||||
|
ringCount: Math.round(value) * 2,
|
||||||
|
ringPlacement: 'ends',
|
||||||
|
ringSpread: node.ringSpread ?? 0.16,
|
||||||
|
ringThickness: node.ringThickness ?? 0.055,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
value={Math.ceil((node.ringCount ?? 0) / 2)}
|
||||||
|
/>
|
||||||
|
{(node.ringCount ?? 0) > 0 && (
|
||||||
|
<SliderControl
|
||||||
|
label="Ring Thickness"
|
||||||
|
max={0.14}
|
||||||
|
min={0.01}
|
||||||
|
onChange={(value) => handleUpdate({ ringThickness: value })}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={node.ringThickness ?? 0.055}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(node.ringCount ?? 0) > 0 && (
|
||||||
|
<SliderControl
|
||||||
|
label="Ring Spread"
|
||||||
|
max={0.45}
|
||||||
|
min={0.04}
|
||||||
|
onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
value={node.ringSpread ?? 0.16}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Ends">
|
||||||
|
<select
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
onChange={(event) => {
|
||||||
|
const capitalStyle = event.target.value as ColumnNode['capitalStyle']
|
||||||
|
handleUpdate({
|
||||||
|
capitalStyle,
|
||||||
|
...(capitalStyle === 'none'
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
capitalHeight: Math.max(node.capitalHeight, 0.12),
|
||||||
|
capitalTierCount: capitalStyle === 'stepped' ? Math.max(node.capitalTierCount ?? 3, 3) : node.capitalTierCount,
|
||||||
|
capitalWidthScale: Math.max(node.capitalWidthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
|
||||||
|
capitalDepthScale: Math.max(node.capitalDepthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
|
||||||
|
capitalStepSpread: capitalStyle === 'stepped' ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) : node.capitalStepSpread,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')}
|
||||||
|
>
|
||||||
|
<option value="none">No Top</option>
|
||||||
|
<option value="simple">Simple Top</option>
|
||||||
|
<option value="stepped">Stepped Top</option>
|
||||||
|
<option value="rounded">Rounded Top</option>
|
||||||
|
</select>
|
||||||
|
{node.capitalStyle !== 'none' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Top Height"
|
||||||
|
max={0.8}
|
||||||
|
min={0.06}
|
||||||
|
onChange={(value) => handleUpdate({ capitalHeight: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
unit="m"
|
||||||
|
value={node.capitalHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.capitalStyle !== 'none' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Top Width"
|
||||||
|
max={2.4}
|
||||||
|
min={0.6}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleUpdate({
|
||||||
|
capitalWidthScale: value,
|
||||||
|
...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
value={node.capitalWidthScale ?? 1.28}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Top Depth"
|
||||||
|
max={2.4}
|
||||||
|
min={0.6}
|
||||||
|
onChange={(value) => handleUpdate({ capitalDepthScale: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.capitalStyle === 'stepped' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Top Tiers"
|
||||||
|
max={8}
|
||||||
|
min={3}
|
||||||
|
onChange={(value) => handleUpdate({ capitalTierCount: Math.round(value) })}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
value={node.capitalTierCount ?? 3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.capitalStyle === 'stepped' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Top Step Spread"
|
||||||
|
max={0.9}
|
||||||
|
min={0.05}
|
||||||
|
onChange={(value) => handleUpdate({ capitalStepSpread: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
value={node.capitalStepSpread ?? 0.34}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
className={`${SELECT_CLASS} mt-2`}
|
||||||
|
onChange={(event) => {
|
||||||
|
const baseStyle = event.target.value as ColumnNode['baseStyle']
|
||||||
|
handleUpdate({
|
||||||
|
baseStyle,
|
||||||
|
...(baseStyle === 'none'
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
baseHeight: Math.max(node.baseHeight, 0.12),
|
||||||
|
baseTierCount: baseStyle === 'stepped-square' ? Math.max(node.baseTierCount ?? 3, 3) : node.baseTierCount,
|
||||||
|
baseWidthScale: Math.max(node.baseWidthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
|
||||||
|
baseDepthScale: Math.max(node.baseDepthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
|
||||||
|
baseStepSpread: baseStyle === 'stepped-square' ? Math.max(node.baseStepSpread ?? 0.34, 0.34) : node.baseStepSpread,
|
||||||
|
basePlinthHeightRatio: baseStyle === 'round-rings' ? (node.basePlinthHeightRatio ?? 0.44) : node.basePlinthHeightRatio,
|
||||||
|
baseRoundBandScale: baseStyle === 'round-rings' ? (node.baseRoundBandScale ?? 0.92) : node.baseRoundBandScale,
|
||||||
|
baseNeckScale: baseStyle === 'round-rings' ? (node.baseNeckScale ?? 0.72) : node.baseNeckScale,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
value={node.baseStyle ?? 'square-plinth'}
|
||||||
|
>
|
||||||
|
<option value="none">No Bottom</option>
|
||||||
|
<option value="simple-square">Simple Block Bottom</option>
|
||||||
|
<option value="square-plinth">Square Plinth Bottom</option>
|
||||||
|
<option value="stepped-square">Stepped Bottom</option>
|
||||||
|
<option value="round-rings">Rounded Bottom</option>
|
||||||
|
</select>
|
||||||
|
{node.baseStyle !== 'none' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Bottom Height"
|
||||||
|
max={0.8}
|
||||||
|
min={0.06}
|
||||||
|
onChange={(value) => handleUpdate({ baseHeight: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
unit="m"
|
||||||
|
value={node.baseHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.baseStyle !== 'none' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Bottom Width"
|
||||||
|
max={2.4}
|
||||||
|
min={0.6}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleUpdate({
|
||||||
|
baseWidthScale: value,
|
||||||
|
...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
value={node.baseWidthScale ?? 1.24}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.baseStyle !== 'none' && node.crossSection === 'rectangular' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Bottom Depth"
|
||||||
|
max={2.4}
|
||||||
|
min={0.6}
|
||||||
|
onChange={(value) => handleUpdate({ baseDepthScale: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.02}
|
||||||
|
value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.baseStyle === 'round-rings' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Plinth Thickness"
|
||||||
|
max={0.7}
|
||||||
|
min={0.2}
|
||||||
|
onChange={(value) => handleUpdate({ basePlinthHeightRatio: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
value={node.basePlinthHeightRatio ?? 0.44}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.baseStyle === 'round-rings' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Round Band Width"
|
||||||
|
max={1.2}
|
||||||
|
min={0.5}
|
||||||
|
onChange={(value) => handleUpdate({ baseRoundBandScale: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
value={node.baseRoundBandScale ?? 0.92}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.baseStyle === 'round-rings' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Neck Width"
|
||||||
|
max={1}
|
||||||
|
min={0.35}
|
||||||
|
onChange={(value) => handleUpdate({ baseNeckScale: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
value={node.baseNeckScale ?? 0.72}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.baseStyle === 'stepped-square' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Bottom Tiers"
|
||||||
|
max={8}
|
||||||
|
min={3}
|
||||||
|
onChange={(value) => handleUpdate({ baseTierCount: Math.round(value) })}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
value={node.baseTierCount ?? 3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.baseStyle === 'stepped-square' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Bottom Step Spread"
|
||||||
|
max={0.9}
|
||||||
|
min={0.05}
|
||||||
|
onChange={(value) => handleUpdate({ baseStepSpread: value })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
value={node.baseStepSpread ?? 0.34}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Transform">
|
||||||
|
<SliderControl
|
||||||
|
label="Yaw"
|
||||||
|
max={180}
|
||||||
|
min={-180}
|
||||||
|
onChange={(value) => handleUpdate({ rotation: (value * Math.PI) / 180 })}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
unit="°"
|
||||||
|
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Actions">
|
||||||
|
<ActionGroup>
|
||||||
|
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||||
|
<ActionButton
|
||||||
|
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||||
|
icon={<Trash2 className="h-4 w-4" />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={handleDelete}
|
||||||
|
/>
|
||||||
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
|
</PanelWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -254,6 +254,7 @@ export function DoorPanel() {
|
|||||||
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
|
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
|
||||||
const isOpening = node.openingKind === 'opening'
|
const isOpening = node.openingKind === 'opening'
|
||||||
const openingShape = node.openingShape ?? 'rectangle'
|
const openingShape = node.openingShape ?? 'rectangle'
|
||||||
|
const doorShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
|
||||||
const openingRadiusMode = node.openingRadiusMode ?? 'all'
|
const openingRadiusMode = node.openingRadiusMode ?? 'all'
|
||||||
const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
|
const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
|
||||||
const cornerRadius = node.cornerRadius ?? 0.15
|
const cornerRadius = node.cornerRadius ?? 0.15
|
||||||
@@ -380,6 +381,108 @@ export function DoorPanel() {
|
|||||||
/>
|
/>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
|
{!isOpening && (
|
||||||
|
<PanelSection title="Top Shape">
|
||||||
|
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) =>
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{doorShape === 'rounded' && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) =>
|
||||||
|
handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Individual', value: 'individual' },
|
||||||
|
]}
|
||||||
|
value={openingRadiusMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{openingRadiusMode === 'all' ? (
|
||||||
|
<SliderControl
|
||||||
|
label="Corner Radius"
|
||||||
|
max={maxRoundedRadius}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => 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]) => (
|
||||||
|
<SliderControl
|
||||||
|
key={label}
|
||||||
|
label={label}
|
||||||
|
max={maxRoundedRadius}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => 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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SliderControl
|
||||||
|
label="Reveal Radius"
|
||||||
|
max={0.08}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewDoorUpdate('openingRevealRadius', v)}
|
||||||
|
onCommit={(v) => commitDoorPreview('openingRevealRadius', v)}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(openingRevealRadius * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{doorShape === 'arch' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Arch Height"
|
||||||
|
max={node.height}
|
||||||
|
min={0.05}
|
||||||
|
onChange={(v) => handleUpdate({ archHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(archHeight * 100) / 100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOpening && (
|
{isOpening && (
|
||||||
<PanelSection title="Opening Shape">
|
<PanelSection title="Opening Shape">
|
||||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||||
@@ -468,6 +571,7 @@ export function DoorPanel() {
|
|||||||
min={0.05}
|
min={0.05}
|
||||||
onChange={(v) => handleUpdate({ archHeight: v })}
|
onChange={(v) => handleUpdate({ archHeight: v })}
|
||||||
precision={2}
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
unit="m"
|
unit="m"
|
||||||
value={Math.round(archHeight * 100) / 100}
|
value={Math.round(archHeight * 100) / 100}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
|
|||||||
window: { icon: '/icons/window.png', label: 'Window' },
|
window: { icon: '/icons/window.png', label: 'Window' },
|
||||||
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
||||||
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
||||||
|
column: { icon: '/icons/column.png', label: 'Column' },
|
||||||
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
||||||
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
||||||
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
type ColumnNode,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
@@ -23,6 +24,7 @@ import { useIsMobile } from '../../../hooks/use-mobile'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CeilingPanel } from './ceiling-panel'
|
import { CeilingPanel } from './ceiling-panel'
|
||||||
|
import { ColumnPanel } from './column-panel'
|
||||||
import { DoorPanel } from './door-panel'
|
import { DoorPanel } from './door-panel'
|
||||||
import { FencePanel } from './fence-panel'
|
import { FencePanel } from './fence-panel'
|
||||||
import { ItemPanel } from './item-panel'
|
import { ItemPanel } from './item-panel'
|
||||||
@@ -45,6 +47,7 @@ type MovableNode =
|
|||||||
| WindowNode
|
| WindowNode
|
||||||
| DoorNode
|
| DoorNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
|
| ColumnNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
| WallNode
|
| WallNode
|
||||||
| FenceNode
|
| FenceNode
|
||||||
@@ -59,6 +62,7 @@ const MOVABLE_TYPES = new Set<string>([
|
|||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
|
'column',
|
||||||
'slab',
|
'slab',
|
||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
@@ -90,6 +94,8 @@ function panelForType(type: string | null) {
|
|||||||
return <SlabPanel />
|
return <SlabPanel />
|
||||||
case 'ceiling':
|
case 'ceiling':
|
||||||
return <CeilingPanel />
|
return <CeilingPanel />
|
||||||
|
case 'column':
|
||||||
|
return <ColumnPanel />
|
||||||
case 'wall':
|
case 'wall':
|
||||||
return <WallPanel />
|
return <WallPanel />
|
||||||
case 'fence':
|
case 'fence':
|
||||||
@@ -250,6 +256,8 @@ export function PanelManager() {
|
|||||||
return <SpawnPanel />
|
return <SpawnPanel />
|
||||||
case 'ceiling':
|
case 'ceiling':
|
||||||
return <CeilingPanel />
|
return <CeilingPanel />
|
||||||
|
case 'column':
|
||||||
|
return <ColumnPanel />
|
||||||
case 'wall':
|
case 'wall':
|
||||||
return <WallPanel />
|
return <WallPanel />
|
||||||
case 'fence':
|
case 'fence':
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
emitter,
|
|
||||||
type GuideNode,
|
type GuideNode,
|
||||||
loadAssetUrl,
|
loadAssetUrl,
|
||||||
saveAsset,
|
saveAsset,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
|
import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { guideEmitter } from '../../../lib/guide-events'
|
||||||
import { getGuideImageName } from '../../../lib/local-guide-image'
|
import { getGuideImageName } from '../../../lib/local-guide-image'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
@@ -98,7 +98,7 @@ export function ReferencePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteNode(selectedReferenceId as AnyNode['id'])
|
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)
|
clearGuideUi(selectedReferenceId)
|
||||||
setSelectedReferenceId(null)
|
setSelectedReferenceId(null)
|
||||||
}, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId])
|
}, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId])
|
||||||
@@ -108,11 +108,11 @@ export function ReferencePanel() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter.emit('guide:set-reference-scale', { guideId: node.id })
|
guideEmitter.emit('guide:set-reference-scale', { guideId: node.id })
|
||||||
}, [node])
|
}, [node])
|
||||||
|
|
||||||
const handleCancelScale = useCallback(() => {
|
const handleCancelScale = useCallback(() => {
|
||||||
emitter.emit('guide:cancel-reference-scale')
|
guideEmitter.emit('guide:cancel-reference-scale')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function isSameRadiusTuple(
|
|||||||
current: [number, number, number, number],
|
current: [number, number, number, number],
|
||||||
next: [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() {
|
export function WindowPanel() {
|
||||||
@@ -267,6 +267,7 @@ export function WindowPanel() {
|
|||||||
const normRows = node.rowRatios.map((r) => r / rowSum)
|
const normRows = node.rowRatios.map((r) => r / rowSum)
|
||||||
const isOpening = node.openingKind === 'opening'
|
const isOpening = node.openingKind === 'opening'
|
||||||
const openingShape = node.openingShape ?? 'rectangle'
|
const openingShape = node.openingShape ?? 'rectangle'
|
||||||
|
const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
|
||||||
const openingRadiusMode = node.openingRadiusMode ?? 'all'
|
const openingRadiusMode = node.openingRadiusMode ?? 'all'
|
||||||
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
|
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
|
||||||
const cornerRadius = node.cornerRadius ?? 0.15
|
const cornerRadius = node.cornerRadius ?? 0.15
|
||||||
@@ -457,6 +458,108 @@ export function WindowPanel() {
|
|||||||
/>
|
/>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
|
{!isOpening && (
|
||||||
|
<PanelSection title="Corner Shape">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(value) =>
|
||||||
|
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' && (
|
||||||
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(value) =>
|
||||||
|
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'individual', label: 'Individual' },
|
||||||
|
]}
|
||||||
|
value={openingRadiusMode}
|
||||||
|
/>
|
||||||
|
{openingRadiusMode === 'all' ? (
|
||||||
|
<SliderControl
|
||||||
|
label="Corner Radius"
|
||||||
|
max={maxRoundedRadius}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => 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]) => (
|
||||||
|
<SliderControl
|
||||||
|
key={label}
|
||||||
|
label={label}
|
||||||
|
max={maxRoundedRadius}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => 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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SliderControl
|
||||||
|
label="Reveal Radius"
|
||||||
|
max={0.08}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
|
||||||
|
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(openingRevealRadius * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{windowShape === 'arch' && (
|
||||||
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
|
<SliderControl
|
||||||
|
label="Arch Height"
|
||||||
|
max={Math.max(0.05, node.height)}
|
||||||
|
min={0.05}
|
||||||
|
onChange={(value) => handleUpdate({ archHeight: value })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(archHeight * 100) / 100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOpening && (
|
{isOpening && (
|
||||||
<PanelSection title="Opening Shape">
|
<PanelSection title="Opening Shape">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
@@ -538,6 +641,7 @@ export function WindowPanel() {
|
|||||||
min={0.05}
|
min={0.05}
|
||||||
onChange={(value) => handleUpdate({ archHeight: value })}
|
onChange={(value) => handleUpdate({ archHeight: value })}
|
||||||
precision={2}
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
unit="m"
|
unit="m"
|
||||||
value={Math.round(archHeight * 100) / 100}
|
value={Math.round(archHeight * 100) / 100}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { type AnyNodeId, type ColumnNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import useEditor from './../../../../../store/use-editor'
|
||||||
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
|
interface ColumnTreeNodeProps {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
depth: number
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnTreeNode = memo(function ColumnTreeNode({
|
||||||
|
nodeId,
|
||||||
|
depth,
|
||||||
|
isLast,
|
||||||
|
}: ColumnTreeNodeProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as ColumnNode | undefined)
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const handled = handleTreeSelection(
|
||||||
|
e,
|
||||||
|
nodeId,
|
||||||
|
useViewer.getState().selection.selectedIds,
|
||||||
|
setSelection,
|
||||||
|
)
|
||||||
|
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeId, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultName = node?.name || 'Column'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||||
|
depth={depth}
|
||||||
|
expanded={false}
|
||||||
|
hasChildren={false}
|
||||||
|
icon={
|
||||||
|
<Image alt="" className="object-contain" height={14} src="/icons/column.png" width={14} />
|
||||||
|
}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLast={isLast}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isVisible={isVisible}
|
||||||
|
label={
|
||||||
|
<InlineRenameInput
|
||||||
|
defaultName={defaultName}
|
||||||
|
isEditing={isEditing}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onStartEditing={() => setIsEditing(true)}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||||
|
onMouseEnter={() => setHoveredId(nodeId)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -56,6 +56,7 @@ export function focusTreeNode(nodeId: AnyNodeId) {
|
|||||||
import { cn } from '../../../../../lib/utils'
|
import { cn } from '../../../../../lib/utils'
|
||||||
import { BuildingTreeNode } from './building-tree-node'
|
import { BuildingTreeNode } from './building-tree-node'
|
||||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||||
|
import { ColumnTreeNode } from './column-tree-node'
|
||||||
import { DoorTreeNode } from './door-tree-node'
|
import { DoorTreeNode } from './door-tree-node'
|
||||||
import { FenceTreeNode } from './fence-tree-node'
|
import { FenceTreeNode } from './fence-tree-node'
|
||||||
import { ItemTreeNode } from './item-tree-node'
|
import { ItemTreeNode } from './item-tree-node'
|
||||||
@@ -84,6 +85,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|||||||
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
|
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
|
||||||
case 'ceiling':
|
case 'ceiling':
|
||||||
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
|
case 'column':
|
||||||
|
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'level':
|
case 'level':
|
||||||
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
||||||
case 'slab':
|
case 'slab':
|
||||||
|
|||||||
@@ -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<GuideEditorEvents>()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
type ColumnNode,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
getCatalogMaterialById,
|
getCatalogMaterialById,
|
||||||
getEffectiveRoofSurfaceMaterial,
|
getEffectiveRoofSurfaceMaterial,
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
|
|
||||||
export type PaintableMaterialTarget = Extract<
|
export type PaintableMaterialTarget = Extract<
|
||||||
MaterialTarget,
|
MaterialTarget,
|
||||||
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
|
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
|
||||||
>
|
>
|
||||||
|
|
||||||
export type SingleSurfaceMaterialRole = 'surface'
|
export type SingleSurfaceMaterialRole = 'surface'
|
||||||
@@ -131,10 +132,9 @@ export function buildStairSurfaceMaterialPatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
|
export function buildSingleSurfaceMaterialPatch<
|
||||||
material: MaterialSchema | undefined,
|
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
|
||||||
materialPreset: string | undefined,
|
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
|
||||||
): Partial<TNode> {
|
|
||||||
return {
|
return {
|
||||||
material,
|
material,
|
||||||
materialPreset,
|
materialPreset,
|
||||||
@@ -170,7 +170,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|||||||
materialPreset: surface.materialPreset,
|
materialPreset: surface.materialPreset,
|
||||||
sourceTarget: 'wall',
|
sourceTarget: 'wall',
|
||||||
})
|
})
|
||||||
? {
|
? {
|
||||||
material: surface.material,
|
material: surface.material,
|
||||||
materialPreset: surface.materialPreset,
|
materialPreset: surface.materialPreset,
|
||||||
sourceTarget: 'wall',
|
sourceTarget: 'wall',
|
||||||
@@ -190,7 +190,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|||||||
materialPreset: surface.materialPreset,
|
materialPreset: surface.materialPreset,
|
||||||
sourceTarget: 'roof',
|
sourceTarget: 'roof',
|
||||||
})
|
})
|
||||||
? {
|
? {
|
||||||
material: surface.material,
|
material: surface.material,
|
||||||
materialPreset: surface.materialPreset,
|
materialPreset: surface.materialPreset,
|
||||||
sourceTarget: 'roof',
|
sourceTarget: 'roof',
|
||||||
@@ -210,7 +210,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|||||||
materialPreset: surface.materialPreset,
|
materialPreset: surface.materialPreset,
|
||||||
sourceTarget: 'stair',
|
sourceTarget: 'stair',
|
||||||
})
|
})
|
||||||
? {
|
? {
|
||||||
material: surface.material,
|
material: surface.material,
|
||||||
materialPreset: surface.materialPreset,
|
materialPreset: surface.materialPreset,
|
||||||
sourceTarget: 'stair',
|
sourceTarget: 'stair',
|
||||||
@@ -220,6 +220,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
(selectedNode.type === 'fence' ||
|
(selectedNode.type === 'fence' ||
|
||||||
|
selectedNode.type === 'column' ||
|
||||||
selectedNode.type === 'slab' ||
|
selectedNode.type === 'slab' ||
|
||||||
selectedNode.type === 'ceiling') &&
|
selectedNode.type === 'ceiling') &&
|
||||||
selectedMaterialTarget.role === 'surface'
|
selectedMaterialTarget.role === 'surface'
|
||||||
@@ -267,6 +268,10 @@ export function resolvePaintTargetFromSelection(params: {
|
|||||||
return 'fence'
|
return 'fence'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'column') {
|
||||||
|
return 'column'
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedNode.type === 'slab') {
|
if (selectedNode.type === 'slab') {
|
||||||
return 'slab'
|
return 'slab'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type AssetInput,
|
type AssetInput,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
type ColumnNode,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
@@ -139,6 +140,7 @@ type EditorState = {
|
|||||||
| DoorNode
|
| DoorNode
|
||||||
| FenceNode
|
| FenceNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
|
| ColumnNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
| WallNode
|
| WallNode
|
||||||
| RoofNode
|
| RoofNode
|
||||||
@@ -155,6 +157,7 @@ type EditorState = {
|
|||||||
| DoorNode
|
| DoorNode
|
||||||
| FenceNode
|
| FenceNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
|
| ColumnNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
| WallNode
|
| WallNode
|
||||||
| RoofNode
|
| RoofNode
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
import { type AnyNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, useScene } from '@pascal-app/core'
|
||||||
import { BuildingRenderer } from './building/building-renderer'
|
import { BuildingRenderer } from './building/building-renderer'
|
||||||
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
||||||
|
import { ColumnRenderer } from './column/column-renderer'
|
||||||
import { DoorRenderer } from './door/door-renderer'
|
import { DoorRenderer } from './door/door-renderer'
|
||||||
import { FenceRenderer } from './fence/fence-renderer'
|
import { FenceRenderer } from './fence/fence-renderer'
|
||||||
import { GuideRenderer } from './guide/guide-renderer'
|
import { GuideRenderer } from './guide/guide-renderer'
|
||||||
@@ -30,6 +31,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
|||||||
{node.type === 'site' && <SiteRenderer node={node} />}
|
{node.type === 'site' && <SiteRenderer node={node} />}
|
||||||
{node.type === 'building' && <BuildingRenderer node={node} />}
|
{node.type === 'building' && <BuildingRenderer node={node} />}
|
||||||
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
|
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
|
||||||
|
{node.type === 'column' && <ColumnRenderer node={node} />}
|
||||||
{node.type === 'level' && <LevelRenderer node={node} />}
|
{node.type === 'level' && <LevelRenderer node={node} />}
|
||||||
{node.type === 'item' && <ItemRenderer node={node} />}
|
{node.type === 'item' && <ItemRenderer node={node} />}
|
||||||
{node.type === 'slab' && <SlabRenderer node={node} />}
|
{node.type === 'slab' && <SlabRenderer node={node} />}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
|
type ColumnNode,
|
||||||
emitter,
|
emitter,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
@@ -32,6 +33,7 @@ type SelectableNodeType =
|
|||||||
| 'fence'
|
| 'fence'
|
||||||
| 'window'
|
| 'window'
|
||||||
| 'door'
|
| 'door'
|
||||||
|
| 'column'
|
||||||
| 'item'
|
| 'item'
|
||||||
| 'slab'
|
| 'slab'
|
||||||
| 'ceiling'
|
| 'ceiling'
|
||||||
@@ -132,6 +134,11 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean =
|
|||||||
return pointInPolygonWithTolerance(item.position[0], item.position[2], zone.polygon)
|
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') {
|
if (node.type === 'wall') {
|
||||||
const wall = node as WallNode
|
const wall = node as WallNode
|
||||||
const startIn = pointInPolygonWithTolerance(wall.start[0], wall.start[1], zone.polygon)
|
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 {
|
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) => {
|
handleClick: (node, nativeEvent) => {
|
||||||
let nodeToSelect = node
|
let nodeToSelect = node
|
||||||
if (node.type === 'roof-segment' && node.parentId) {
|
if (node.type === 'roof-segment' && node.parentId) {
|
||||||
@@ -258,6 +276,7 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
|
'column',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
@@ -318,6 +337,7 @@ export const SelectionManager = () => {
|
|||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
|
'column',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingEvent,
|
type CeilingEvent,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
type ColumnEvent,
|
||||||
|
type ColumnNode,
|
||||||
type DoorEvent,
|
type DoorEvent,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
type EventSuffix,
|
type EventSuffix,
|
||||||
@@ -48,6 +50,7 @@ type NodeConfig = {
|
|||||||
slab: { node: SlabNode; event: SlabEvent }
|
slab: { node: SlabNode; event: SlabEvent }
|
||||||
spawn: { node: SpawnNode; event: SpawnEvent }
|
spawn: { node: SpawnNode; event: SpawnEvent }
|
||||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||||
|
column: { node: ColumnNode; event: ColumnEvent }
|
||||||
roof: { node: RoofNode; event: RoofEvent }
|
roof: { node: RoofNode; event: RoofEvent }
|
||||||
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
|
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
|
||||||
stair: { node: StairNode; event: StairEvent }
|
stair: { node: StairNode; event: StairEvent }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
|
import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import {
|
|
||||||
type AnyNodeId,
|
|
||||||
type DoorNode,
|
|
||||||
sceneRegistry,
|
|
||||||
useScene,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { baseMaterial, glassMaterial } from '../../lib/materials'
|
import { baseMaterial, glassMaterial } from '../../lib/materials'
|
||||||
|
|
||||||
@@ -55,6 +50,300 @@ function addBox(
|
|||||||
parent.add(m)
|
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) {
|
function disposeObject(object: THREE.Object3D) {
|
||||||
object.traverse((child) => {
|
object.traverse((child) => {
|
||||||
if (child instanceof THREE.Mesh) child.geometry.dispose()
|
if (child instanceof THREE.Mesh) child.geometry.dispose()
|
||||||
@@ -82,6 +371,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
openingKind,
|
openingKind,
|
||||||
|
openingShape,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
threshold,
|
threshold,
|
||||||
@@ -129,41 +419,111 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
y: number,
|
y: number,
|
||||||
z: number,
|
z: number,
|
||||||
) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z)
|
) => 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 ──
|
// ── Frame members ──
|
||||||
// Left post — full height
|
if (openingShape === 'arch') {
|
||||||
addBox(
|
const frameBottom = -height / 2
|
||||||
mesh,
|
const frameTop = height / 2
|
||||||
baseMaterial,
|
const frameArchHeight = getClampedArchHeight(width, height, node.archHeight)
|
||||||
frameThickness,
|
const frameSpringY = frameTop - frameArchHeight
|
||||||
height,
|
const frameInnerTopY = frameTop - frameThickness
|
||||||
frameDepth,
|
const frameInnerSpringY = Math.min(frameSpringY + frameThickness, frameInnerTopY)
|
||||||
-width / 2 + frameThickness / 2,
|
const useShallowHeadBar = frameArchHeight <= frameThickness * 2
|
||||||
0,
|
const frameHeadBottomY = useShallowHeadBar ? frameSpringY - frameThickness : frameSpringY
|
||||||
0,
|
const postHeight = Math.max(frameHeadBottomY - frameBottom, 0.01)
|
||||||
)
|
|
||||||
// Right post — full height
|
addBox(
|
||||||
addBox(
|
mesh,
|
||||||
mesh,
|
baseMaterial,
|
||||||
baseMaterial,
|
frameThickness,
|
||||||
frameThickness,
|
postHeight,
|
||||||
height,
|
frameDepth,
|
||||||
frameDepth,
|
-width / 2 + frameThickness / 2,
|
||||||
width / 2 - frameThickness / 2,
|
frameBottom + postHeight / 2,
|
||||||
0,
|
0,
|
||||||
0,
|
)
|
||||||
)
|
addBox(
|
||||||
// Head (top bar) — full width
|
mesh,
|
||||||
addBox(
|
baseMaterial,
|
||||||
mesh,
|
frameThickness,
|
||||||
baseMaterial,
|
postHeight,
|
||||||
width,
|
frameDepth,
|
||||||
frameThickness,
|
width / 2 - frameThickness / 2,
|
||||||
frameDepth,
|
frameBottom + postHeight / 2,
|
||||||
0,
|
0,
|
||||||
height / 2 - frameThickness / 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) ──
|
// ── Threshold (inside the frame) ──
|
||||||
if (threshold) {
|
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 cpX = contentPadding[0]
|
||||||
const cpY = contentPadding[1]
|
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
|
// Top strip
|
||||||
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
|
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
|
||||||
// Bottom strip
|
// Bottom strip
|
||||||
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)
|
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
|
const innerH = leafH - 2 * cpY
|
||||||
// Left strip
|
// Left strip
|
||||||
addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
|
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
|
const contentTop = leafCenterY + contentH / 2
|
||||||
|
|
||||||
let segY = contentTop
|
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 segH = (seg.heightRatio / totalRatio) * contentH
|
||||||
const segCenterY = segY - segH / 2
|
const segCenterY = segY - segH / 2
|
||||||
|
const segTop = segY
|
||||||
|
const segBottom = segY - segH
|
||||||
|
|
||||||
const numCols = seg.columnRatios.length
|
const numCols = seg.columnRatios.length
|
||||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||||
@@ -228,15 +714,24 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
cx = -contentW / 2
|
cx = -contentW / 2
|
||||||
for (let c = 0; c < numCols - 1; c++) {
|
for (let c = 0; c < numCols - 1; c++) {
|
||||||
cx += colWidths[c]!
|
cx += colWidths[c]!
|
||||||
addLeafBox(
|
if (usesShapedLeaf) {
|
||||||
baseMaterial,
|
const dividerLeft = cx
|
||||||
seg.dividerThickness,
|
const dividerRight = cx + seg.dividerThickness
|
||||||
segH,
|
const dividerShape = createLeafCellShape(dividerLeft, dividerRight, segBottom, segTop)
|
||||||
leafDepth + 0.001,
|
if (dividerShape) {
|
||||||
cx + seg.dividerThickness / 2,
|
addLeafShape(dividerShape, baseMaterial, 0.012, leafDepth / 2 + 0.006)
|
||||||
segCenterY,
|
}
|
||||||
0,
|
} else {
|
||||||
)
|
addLeafBox(
|
||||||
|
baseMaterial,
|
||||||
|
seg.dividerThickness,
|
||||||
|
segH,
|
||||||
|
leafDepth + 0.001,
|
||||||
|
cx + seg.dividerThickness / 2,
|
||||||
|
segCenterY,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
cx += seg.dividerThickness
|
cx += seg.dividerThickness
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,27 +740,61 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
for (let c = 0; c < numCols; c++) {
|
for (let c = 0; c < numCols; c++) {
|
||||||
const colW = colWidths[c]!
|
const colW = colWidths[c]!
|
||||||
const colX = colXCenters[c]!
|
const colX = colXCenters[c]!
|
||||||
|
const cellLeft = colX - colW / 2
|
||||||
|
const cellRight = colX + colW / 2
|
||||||
|
|
||||||
if (seg.type === 'glass') {
|
if (seg.type === 'glass') {
|
||||||
// Glass only — no opaque backing so it's truly transparent
|
|
||||||
const glassDepth = Math.max(0.004, leafDepth * 0.15)
|
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') {
|
} else if (seg.type === 'panel') {
|
||||||
// Opaque leaf backing for this column
|
if (usesShapedLeaf) {
|
||||||
addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
|
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
|
// Raised panel detail
|
||||||
const panelW = colW - 2 * seg.panelInset
|
const panelW = colW - 2 * seg.panelInset
|
||||||
const panelH = segH - 2 * seg.panelInset
|
const panelH = segH - 2 * seg.panelInset
|
||||||
if (panelW > 0.01 && panelH > 0.01) {
|
if (panelW > 0.01 && panelH > 0.01) {
|
||||||
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
|
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
|
||||||
const panelZ = leafDepth / 2 + effectiveDepth / 2
|
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 {
|
} else {
|
||||||
// 'empty' leaves the opening unfilled
|
// '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
|
segY -= segH
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,8 +837,6 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
const hingeW = 0.024
|
const hingeW = 0.024
|
||||||
const hingeD = leafDepth + 0.016
|
const hingeD = leafDepth + 0.016
|
||||||
// Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top
|
// 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 + 0.25, hingeZ)
|
||||||
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ)
|
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ)
|
||||||
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, 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)
|
mesh.add(cutout)
|
||||||
}
|
}
|
||||||
cutout.geometry.dispose()
|
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
|
cutout.visible = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
calculateLevelMiters,
|
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
calculateLevelMiters,
|
||||||
|
DEFAULT_WALL_HEIGHT,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
getAdjacentWallIds,
|
getAdjacentWallIds,
|
||||||
DEFAULT_WALL_HEIGHT,
|
|
||||||
getWallCurveFrameAt,
|
getWallCurveFrameAt,
|
||||||
getWallMiterBoundaryPoints,
|
getWallMiterBoundaryPoints,
|
||||||
getWallPlanFootprint,
|
getWallPlanFootprint,
|
||||||
@@ -21,10 +17,14 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
|
||||||
type WallMiterData,
|
type WallMiterData,
|
||||||
|
type WallNode,
|
||||||
type WindowNode,
|
type WindowNode,
|
||||||
} from '@pascal-app/core'
|
} 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
|
// Reusable CSG evaluator for better performance
|
||||||
const csgEvaluator = new Evaluator()
|
const csgEvaluator = new Evaluator()
|
||||||
@@ -560,7 +560,13 @@ function collectCutoutBrushes(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
(child.type === 'door' && child.openingKind === 'opening') ||
|
(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))
|
brushes.push(createShapedOpeningCutoutBrush(child, wallThickness))
|
||||||
continue
|
continue
|
||||||
@@ -668,11 +674,17 @@ function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape
|
|||||||
if (opening.openingShape === 'arch') {
|
if (opening.openingShape === 'arch') {
|
||||||
const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height)
|
const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height)
|
||||||
const springY = top - archHeight
|
const springY = top - archHeight
|
||||||
|
const segments = 32
|
||||||
|
|
||||||
shape.moveTo(left, bottom)
|
shape.moveTo(left, bottom)
|
||||||
shape.lineTo(right, bottom)
|
shape.lineTo(right, bottom)
|
||||||
shape.lineTo(right, springY)
|
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.lineTo(left, bottom)
|
||||||
shape.closePath()
|
shape.closePath()
|
||||||
return shape
|
return shape
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
|
import { type AnyNodeId, sceneRegistry, useScene, type WindowNode } from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import {
|
|
||||||
type AnyNodeId,
|
|
||||||
sceneRegistry,
|
|
||||||
useScene,
|
|
||||||
type WindowNode,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { baseMaterial, glassMaterial } from '../../lib/materials'
|
import { baseMaterial, glassMaterial } from '../../lib/materials'
|
||||||
|
|
||||||
@@ -55,6 +50,521 @@ function addBox(
|
|||||||
parent.add(m)
|
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) {
|
function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||||
// Root mesh is an invisible hitbox; all visuals live in child meshes
|
// Root mesh is an invisible hitbox; all visuals live in child meshes
|
||||||
mesh.geometry.dispose()
|
mesh.geometry.dispose()
|
||||||
@@ -85,6 +595,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
sillDepth,
|
sillDepth,
|
||||||
sillThickness,
|
sillThickness,
|
||||||
openingKind,
|
openingKind,
|
||||||
|
openingShape,
|
||||||
} = node
|
} = node
|
||||||
|
|
||||||
if (openingKind === 'opening') {
|
if (openingKind === 'opening') {
|
||||||
@@ -92,6 +603,18 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
return
|
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 innerW = width - 2 * frameThickness
|
||||||
const innerH = height - 2 * frameThickness
|
const innerH = height - 2 * frameThickness
|
||||||
|
|
||||||
@@ -252,6 +775,40 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
mesh.add(cutout)
|
mesh.add(cutout)
|
||||||
}
|
}
|
||||||
cutout.geometry.dispose()
|
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
|
cutout.visible = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user