@pascal-app/editor 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
|
|
2
|
+
import {
|
|
3
|
+
clampPlanValue,
|
|
4
|
+
getPlanPointDistance,
|
|
5
|
+
getThickPlanLinePolygon,
|
|
6
|
+
interpolatePlanPoint,
|
|
7
|
+
movePlanPointTowards,
|
|
8
|
+
rotatePlanVector,
|
|
9
|
+
} from './geometry'
|
|
10
|
+
import type {
|
|
11
|
+
FloorplanLineSegment,
|
|
12
|
+
FloorplanStairArrowEntry,
|
|
13
|
+
FloorplanStairEntry,
|
|
14
|
+
FloorplanStairSegmentEntry,
|
|
15
|
+
StairSegmentTransform,
|
|
16
|
+
} from './types'
|
|
17
|
+
|
|
18
|
+
const FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS = 0.05
|
|
19
|
+
const FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION = 0.18
|
|
20
|
+
const FLOORPLAN_STAIR_TREAD_BAND_THICKNESS = 0.05 * 0.82
|
|
21
|
+
const FLOORPLAN_STAIR_TREAD_MIN_THICKNESS = 0.02 * 1.5
|
|
22
|
+
const FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE = 0.14
|
|
23
|
+
const FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE = 0.24
|
|
24
|
+
|
|
25
|
+
type FloorplanStairArrowSide = 'back' | 'front' | 'left' | 'right'
|
|
26
|
+
|
|
27
|
+
function getFloorplanStairSegmentCenterLine(polygon: Point2D[]): FloorplanLineSegment | null {
|
|
28
|
+
if (polygon.length < 4) {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [backLeft, backRight, frontRight, frontLeft] = polygon
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
start: interpolatePlanPoint(backLeft!, backRight!, 0.5),
|
|
36
|
+
end: interpolatePlanPoint(frontLeft!, frontRight!, 0.5),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getFloorplanStairInnerPolygon(polygon: Point2D[]): Point2D[] {
|
|
41
|
+
if (polygon.length < 4) {
|
|
42
|
+
return polygon
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const [backLeft, backRight, frontRight, frontLeft] = polygon
|
|
46
|
+
const outerWidth = getPlanPointDistance(backLeft!, backRight!)
|
|
47
|
+
const outerLength = getPlanPointDistance(backLeft!, frontLeft!)
|
|
48
|
+
const widthInset = Math.min(
|
|
49
|
+
FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS,
|
|
50
|
+
outerWidth * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION,
|
|
51
|
+
)
|
|
52
|
+
const lengthInset = Math.min(
|
|
53
|
+
FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS,
|
|
54
|
+
outerLength * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const insetBackLeft = movePlanPointTowards(backLeft!, frontLeft!, lengthInset)
|
|
58
|
+
const insetBackRight = movePlanPointTowards(backRight!, frontRight!, lengthInset)
|
|
59
|
+
const insetFrontLeft = movePlanPointTowards(frontLeft!, backLeft!, lengthInset)
|
|
60
|
+
const insetFrontRight = movePlanPointTowards(frontRight!, backRight!, lengthInset)
|
|
61
|
+
|
|
62
|
+
const innerPolygon = [
|
|
63
|
+
movePlanPointTowards(insetBackLeft, insetBackRight, widthInset),
|
|
64
|
+
movePlanPointTowards(insetBackRight, insetBackLeft, widthInset),
|
|
65
|
+
movePlanPointTowards(insetFrontRight, insetFrontLeft, widthInset),
|
|
66
|
+
movePlanPointTowards(insetFrontLeft, insetFrontRight, widthInset),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!)
|
|
70
|
+
const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!)
|
|
71
|
+
|
|
72
|
+
return innerWidth > 0.06 && innerLength > 0.06 ? innerPolygon : polygon
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getFloorplanStairTreadLines(
|
|
76
|
+
segment: StairSegmentNode,
|
|
77
|
+
innerPolygon: Point2D[],
|
|
78
|
+
): FloorplanLineSegment[] {
|
|
79
|
+
if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) {
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const [backLeft, backRight, frontRight, frontLeft] = innerPolygon
|
|
84
|
+
const treadLines: FloorplanLineSegment[] = []
|
|
85
|
+
|
|
86
|
+
for (let stepIndex = 1; stepIndex < segment.stepCount; stepIndex += 1) {
|
|
87
|
+
const t = stepIndex / segment.stepCount
|
|
88
|
+
treadLines.push({
|
|
89
|
+
start: interpolatePlanPoint(backLeft!, frontLeft!, t),
|
|
90
|
+
end: interpolatePlanPoint(backRight!, frontRight!, t),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return treadLines
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getFloorplanStairTreadThickness(segment: StairSegmentNode, innerPolygon: Point2D[]) {
|
|
98
|
+
if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) {
|
|
99
|
+
return 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!)
|
|
103
|
+
const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!)
|
|
104
|
+
const treadRun = innerLength / Math.max(segment.stepCount, 1)
|
|
105
|
+
return clampPlanValue(
|
|
106
|
+
Math.min(FLOORPLAN_STAIR_TREAD_BAND_THICKNESS, innerWidth * 0.12, treadRun * 0.44),
|
|
107
|
+
FLOORPLAN_STAIR_TREAD_MIN_THICKNESS,
|
|
108
|
+
FLOORPLAN_STAIR_TREAD_BAND_THICKNESS,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getFloorplanStairTreadBars(
|
|
113
|
+
segment: StairSegmentNode,
|
|
114
|
+
innerPolygon: Point2D[],
|
|
115
|
+
treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon),
|
|
116
|
+
): Point2D[][] {
|
|
117
|
+
const treadLines = getFloorplanStairTreadLines(segment, innerPolygon)
|
|
118
|
+
if (treadLines.length === 0 || treadThickness <= 0) {
|
|
119
|
+
return []
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return treadLines.map((line) => getThickPlanLinePolygon(line, treadThickness))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getFloorplanStairSegmentCenterPoint(segment: FloorplanStairSegmentEntry): Point2D | null {
|
|
126
|
+
if (segment.centerLine) {
|
|
127
|
+
return interpolatePlanPoint(segment.centerLine.start, segment.centerLine.end, 0.5)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (segment.polygon.length < 4) {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const [backLeft, backRight, frontRight, frontLeft] = segment.polygon
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
x: (backLeft!.x + backRight!.x + frontRight!.x + frontLeft!.x) / 4,
|
|
138
|
+
y: (backLeft!.y + backRight!.y + frontRight!.y + frontLeft!.y) / 4,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getFloorplanStairSegmentSidePoint(
|
|
143
|
+
segment: FloorplanStairSegmentEntry,
|
|
144
|
+
side: FloorplanStairArrowSide,
|
|
145
|
+
): Point2D | null {
|
|
146
|
+
if (segment.polygon.length < 4) {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const [backLeft, backRight, frontRight, frontLeft] = segment.polygon
|
|
151
|
+
|
|
152
|
+
switch (side) {
|
|
153
|
+
case 'back':
|
|
154
|
+
return interpolatePlanPoint(backLeft!, backRight!, 0.5)
|
|
155
|
+
case 'front':
|
|
156
|
+
return interpolatePlanPoint(frontLeft!, frontRight!, 0.5)
|
|
157
|
+
case 'left':
|
|
158
|
+
return interpolatePlanPoint(backLeft!, frontLeft!, 0.5)
|
|
159
|
+
case 'right':
|
|
160
|
+
return interpolatePlanPoint(backRight!, frontRight!, 0.5)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getFloorplanStairExitSide(
|
|
165
|
+
nextSegment: StairSegmentNode | undefined,
|
|
166
|
+
): FloorplanStairArrowSide {
|
|
167
|
+
if (!nextSegment) {
|
|
168
|
+
return 'front'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (nextSegment.attachmentSide === 'left') {
|
|
172
|
+
return 'right'
|
|
173
|
+
}
|
|
174
|
+
if (nextSegment.attachmentSide === 'right') {
|
|
175
|
+
return 'left'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return 'front'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function appendUniquePlanPoint(points: Point2D[], point: Point2D | null) {
|
|
182
|
+
if (!point) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const lastPoint = points[points.length - 1]
|
|
187
|
+
if (lastPoint && getPlanPointDistance(lastPoint, point) <= 0.001) {
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
points.push(point)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getFloorplanArcPoint(center: Point2D, radius: number, angle: number): Point2D {
|
|
195
|
+
return {
|
|
196
|
+
x: center.x + Math.cos(angle) * radius,
|
|
197
|
+
y: center.y + Math.sin(angle) * radius,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
|
|
202
|
+
const stairType = stair.stairType ?? 'straight'
|
|
203
|
+
const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
|
|
204
|
+
|
|
205
|
+
if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
|
|
206
|
+
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return baseSweepAngle
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
|
|
213
|
+
if (
|
|
214
|
+
(stair.stairType ?? 'straight') !== 'spiral' ||
|
|
215
|
+
(stair.topLandingMode ?? 'none') !== 'integrated'
|
|
216
|
+
) {
|
|
217
|
+
return 0
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9)
|
|
221
|
+
const width = Math.max(stair.width ?? 1, 0.4)
|
|
222
|
+
const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8))
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) *
|
|
226
|
+
Math.sign(sweepAngle || 1)
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
|
|
231
|
+
const stairType = stair.stairType ?? 'straight'
|
|
232
|
+
const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
|
|
233
|
+
const startAngle = -stair.rotation - sweepAngle / 2
|
|
234
|
+
const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle)
|
|
235
|
+
const center = {
|
|
236
|
+
x: stair.position[0],
|
|
237
|
+
y: stair.position[2],
|
|
238
|
+
}
|
|
239
|
+
const innerRadius = Math.max(
|
|
240
|
+
stairType === 'spiral' ? 0.05 : 0.2,
|
|
241
|
+
stair.innerRadius ?? (stairType === 'spiral' ? 0.2 : 0.9),
|
|
242
|
+
)
|
|
243
|
+
const outerRadius = innerRadius + stair.width
|
|
244
|
+
const outerArcLength = Math.abs(sweepAngle) * outerRadius
|
|
245
|
+
const segmentCount = Math.max(
|
|
246
|
+
24,
|
|
247
|
+
Math.ceil(Math.abs(sweepAngle) / (Math.PI / 24)),
|
|
248
|
+
Math.ceil(outerArcLength / 0.14),
|
|
249
|
+
)
|
|
250
|
+
const outerPoints: Point2D[] = []
|
|
251
|
+
const innerPoints: Point2D[] = []
|
|
252
|
+
|
|
253
|
+
for (let index = 0; index <= segmentCount; index += 1) {
|
|
254
|
+
const t = index / segmentCount
|
|
255
|
+
const angle = startAngle + (endAngle - startAngle) * t
|
|
256
|
+
outerPoints.push(getFloorplanArcPoint(center, outerRadius, angle))
|
|
257
|
+
innerPoints.push(getFloorplanArcPoint(center, innerRadius, angle))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return [...outerPoints, ...innerPoints.reverse()]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildFloorplanStairArrow(
|
|
264
|
+
segments: FloorplanStairSegmentEntry[],
|
|
265
|
+
): FloorplanStairArrowEntry | null {
|
|
266
|
+
const rawPoints: Point2D[] = []
|
|
267
|
+
|
|
268
|
+
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
|
|
269
|
+
const segment = segments[segmentIndex]!
|
|
270
|
+
const nextSegment = segments[segmentIndex + 1]?.segment
|
|
271
|
+
const entryPoint = getFloorplanStairSegmentSidePoint(segment, 'back')
|
|
272
|
+
const exitPoint = getFloorplanStairSegmentSidePoint(
|
|
273
|
+
segment,
|
|
274
|
+
getFloorplanStairExitSide(nextSegment),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if (!(entryPoint && exitPoint)) {
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
appendUniquePlanPoint(rawPoints, entryPoint)
|
|
282
|
+
|
|
283
|
+
const isStraightSegment = getPlanPointDistance(entryPoint, exitPoint) <= 0.001
|
|
284
|
+
if (isStraightSegment) {
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const exitSide = getFloorplanStairExitSide(nextSegment)
|
|
289
|
+
if (exitSide === 'front') {
|
|
290
|
+
appendUniquePlanPoint(rawPoints, exitPoint)
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
appendUniquePlanPoint(rawPoints, getFloorplanStairSegmentCenterPoint(segment))
|
|
295
|
+
appendUniquePlanPoint(rawPoints, exitPoint)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (rawPoints.length < 2) {
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const firstPoint = rawPoints[0]!
|
|
303
|
+
const secondPoint = rawPoints[1]!
|
|
304
|
+
const beforeLastPoint = rawPoints[rawPoints.length - 2]!
|
|
305
|
+
const lastPoint = rawPoints[rawPoints.length - 1]!
|
|
306
|
+
const firstLength = getPlanPointDistance(firstPoint, secondPoint)
|
|
307
|
+
const lastLength = getPlanPointDistance(beforeLastPoint, lastPoint)
|
|
308
|
+
|
|
309
|
+
if (firstLength <= Number.EPSILON || lastLength <= Number.EPSILON) {
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const polyline = [
|
|
314
|
+
movePlanPointTowards(firstPoint, secondPoint, Math.min(0.24, firstLength * 0.18)),
|
|
315
|
+
...rawPoints.slice(1, -1),
|
|
316
|
+
movePlanPointTowards(lastPoint, beforeLastPoint, Math.min(0.3, lastLength * 0.22)),
|
|
317
|
+
]
|
|
318
|
+
const arrowTailPoint = polyline[polyline.length - 2]
|
|
319
|
+
const arrowTip = polyline[polyline.length - 1]
|
|
320
|
+
|
|
321
|
+
if (!(arrowTailPoint && arrowTip)) {
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const arrowBodyLength = getPlanPointDistance(arrowTailPoint, arrowTip)
|
|
326
|
+
if (arrowBodyLength <= Number.EPSILON) {
|
|
327
|
+
return null
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const arrowHeadLength = clampPlanValue(
|
|
331
|
+
arrowBodyLength * 0.72,
|
|
332
|
+
FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE,
|
|
333
|
+
FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE,
|
|
334
|
+
)
|
|
335
|
+
const arrowHeadBase = movePlanPointTowards(arrowTip, arrowTailPoint, arrowHeadLength)
|
|
336
|
+
const directionX = arrowTip.x - arrowHeadBase.x
|
|
337
|
+
const directionY = arrowTip.y - arrowHeadBase.y
|
|
338
|
+
const directionLength = Math.hypot(directionX, directionY)
|
|
339
|
+
|
|
340
|
+
if (directionLength <= Number.EPSILON) {
|
|
341
|
+
return null
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const normalX = -directionY / directionLength
|
|
345
|
+
const normalY = directionX / directionLength
|
|
346
|
+
const arrowHeadHalfWidth = arrowHeadLength * 0.34
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
head: [
|
|
350
|
+
arrowTip,
|
|
351
|
+
{
|
|
352
|
+
x: arrowHeadBase.x + normalX * arrowHeadHalfWidth,
|
|
353
|
+
y: arrowHeadBase.y + normalY * arrowHeadHalfWidth,
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
x: arrowHeadBase.x - normalX * arrowHeadHalfWidth,
|
|
357
|
+
y: arrowHeadBase.y - normalY * arrowHeadHalfWidth,
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
polyline,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function computeFloorplanStairSegmentTransforms(
|
|
365
|
+
segments: StairSegmentNode[],
|
|
366
|
+
): StairSegmentTransform[] {
|
|
367
|
+
const transforms: StairSegmentTransform[] = []
|
|
368
|
+
let currentX = 0
|
|
369
|
+
let currentY = 0
|
|
370
|
+
let currentZ = 0
|
|
371
|
+
let currentRotation = 0
|
|
372
|
+
|
|
373
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
374
|
+
const segment = segments[index]!
|
|
375
|
+
|
|
376
|
+
if (index === 0) {
|
|
377
|
+
transforms.push({
|
|
378
|
+
position: [currentX, currentY, currentZ],
|
|
379
|
+
rotation: currentRotation,
|
|
380
|
+
})
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const previousSegment = segments[index - 1]!
|
|
385
|
+
let attachX = 0
|
|
386
|
+
let attachY = previousSegment.height
|
|
387
|
+
let attachZ = previousSegment.length
|
|
388
|
+
let rotationDelta = 0
|
|
389
|
+
|
|
390
|
+
if (segment.attachmentSide === 'left') {
|
|
391
|
+
attachX = previousSegment.width / 2
|
|
392
|
+
attachZ = previousSegment.length / 2
|
|
393
|
+
rotationDelta = Math.PI / 2
|
|
394
|
+
} else if (segment.attachmentSide === 'right') {
|
|
395
|
+
attachX = -previousSegment.width / 2
|
|
396
|
+
attachZ = previousSegment.length / 2
|
|
397
|
+
rotationDelta = -Math.PI / 2
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const [rotatedAttachX, rotatedAttachZ] = rotatePlanVector(attachX, attachZ, currentRotation)
|
|
401
|
+
currentX += rotatedAttachX
|
|
402
|
+
currentY += attachY
|
|
403
|
+
currentZ += rotatedAttachZ
|
|
404
|
+
currentRotation += rotationDelta
|
|
405
|
+
|
|
406
|
+
transforms.push({
|
|
407
|
+
position: [currentX, currentY, currentZ],
|
|
408
|
+
rotation: currentRotation,
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return transforms
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function getFloorplanStairSegmentPolygon(
|
|
416
|
+
stair: StairNode,
|
|
417
|
+
segment: StairSegmentNode,
|
|
418
|
+
transform: StairSegmentTransform,
|
|
419
|
+
): Point2D[] {
|
|
420
|
+
const halfWidth = segment.width / 2
|
|
421
|
+
const localCorners: Array<[number, number]> = [
|
|
422
|
+
[-halfWidth, 0],
|
|
423
|
+
[halfWidth, 0],
|
|
424
|
+
[halfWidth, segment.length],
|
|
425
|
+
[-halfWidth, segment.length],
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
return localCorners.map(([localX, localY]) => {
|
|
429
|
+
const [segmentX, segmentY] = rotatePlanVector(localX, localY, transform.rotation)
|
|
430
|
+
const groupX = transform.position[0] + segmentX
|
|
431
|
+
const groupY = transform.position[2] + segmentY
|
|
432
|
+
const [worldOffsetX, worldOffsetY] = rotatePlanVector(groupX, groupY, stair.rotation)
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
x: stair.position[0] + worldOffsetX,
|
|
436
|
+
y: stair.position[2] + worldOffsetY,
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function buildFloorplanStairEntry(
|
|
442
|
+
stair: StairNode,
|
|
443
|
+
segments: StairSegmentNode[],
|
|
444
|
+
): FloorplanStairEntry | null {
|
|
445
|
+
const stairType = stair.stairType ?? 'straight'
|
|
446
|
+
|
|
447
|
+
if (segments.length === 0 && stairType === 'straight') {
|
|
448
|
+
return null
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const transforms = computeFloorplanStairSegmentTransforms(segments)
|
|
452
|
+
const segmentEntries = segments.map((segment, index) => {
|
|
453
|
+
const polygon = getFloorplanStairSegmentPolygon(stair, segment, transforms[index]!)
|
|
454
|
+
const centerLine = getFloorplanStairSegmentCenterLine(polygon)
|
|
455
|
+
const innerPolygon = getFloorplanStairInnerPolygon(polygon)
|
|
456
|
+
const treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon)
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
centerLine,
|
|
460
|
+
innerPolygon,
|
|
461
|
+
segment,
|
|
462
|
+
polygon,
|
|
463
|
+
treadBars: getFloorplanStairTreadBars(segment, innerPolygon, treadThickness),
|
|
464
|
+
treadThickness,
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
const hitPolygons =
|
|
468
|
+
stairType === 'straight'
|
|
469
|
+
? segmentEntries.map(({ polygon }) => polygon)
|
|
470
|
+
: [getFloorplanCurvedStairHitPolygon(stair)]
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
arrow: buildFloorplanStairArrow(segmentEntries),
|
|
474
|
+
hitPolygons,
|
|
475
|
+
stair,
|
|
476
|
+
segments: segmentEntries,
|
|
477
|
+
}
|
|
478
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AnyNode, ItemNode, Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export type FloorplanNodeTransform = {
|
|
4
|
+
position: Point2D
|
|
5
|
+
rotation: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type FloorplanLineSegment = {
|
|
9
|
+
start: Point2D
|
|
10
|
+
end: Point2D
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type FloorplanItemEntry = {
|
|
14
|
+
dimensionPolygon: Point2D[]
|
|
15
|
+
item: ItemNode
|
|
16
|
+
polygon: Point2D[]
|
|
17
|
+
usesRealMesh: boolean
|
|
18
|
+
center: Point2D
|
|
19
|
+
rotation: number
|
|
20
|
+
width: number
|
|
21
|
+
depth: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FloorplanStairSegmentEntry = {
|
|
25
|
+
centerLine: FloorplanLineSegment | null
|
|
26
|
+
innerPolygon: Point2D[]
|
|
27
|
+
segment: StairSegmentNode
|
|
28
|
+
polygon: Point2D[]
|
|
29
|
+
treadBars: Point2D[][]
|
|
30
|
+
treadThickness: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type FloorplanStairArrowEntry = {
|
|
34
|
+
head: Point2D[]
|
|
35
|
+
polyline: Point2D[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type FloorplanStairEntry = {
|
|
39
|
+
arrow: FloorplanStairArrowEntry | null
|
|
40
|
+
hitPolygons: Point2D[][]
|
|
41
|
+
stair: StairNode
|
|
42
|
+
segments: FloorplanStairSegmentEntry[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type FloorplanSelectionBounds = {
|
|
46
|
+
minX: number
|
|
47
|
+
maxX: number
|
|
48
|
+
minY: number
|
|
49
|
+
maxY: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type StairSegmentTransform = {
|
|
53
|
+
position: [number, number, number]
|
|
54
|
+
rotation: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type LevelDescendantMap = ReadonlyMap<string, AnyNode>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { WallNode } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18
|
|
4
|
+
const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13
|
|
5
|
+
const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035
|
|
6
|
+
|
|
7
|
+
export function getFloorplanWallThickness(wall: WallNode): number {
|
|
8
|
+
const baseThickness = wall.thickness ?? 0.1
|
|
9
|
+
const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE
|
|
10
|
+
|
|
11
|
+
return Math.min(
|
|
12
|
+
baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS,
|
|
13
|
+
Math.max(baseThickness, scaledThickness, FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS),
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getFloorplanWall(wall: WallNode): WallNode {
|
|
18
|
+
return {
|
|
19
|
+
...wall,
|
|
20
|
+
// Slightly exaggerate thin walls so the 2D plan stays legible without drifting from BIM data.
|
|
21
|
+
thickness: getFloorplanWallThickness(wall),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GuideNode } from '@pascal-app/core'
|
|
2
|
+
import mitt from 'mitt'
|
|
3
|
+
|
|
4
|
+
type GuideEditorEvents = {
|
|
5
|
+
'guide:set-reference-scale': { guideId: GuideNode['id'] }
|
|
6
|
+
'guide:cancel-reference-scale': undefined
|
|
7
|
+
'guide:deleted': { guideId: GuideNode['id'] }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const guideEmitter = mitt<GuideEditorEvents>()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useLiveTransforms, useScene } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
function refreshSceneAfterHistoryJump() {
|
|
4
|
+
useLiveTransforms.getState().clearAll()
|
|
5
|
+
|
|
6
|
+
const state = useScene.getState()
|
|
7
|
+
for (const node of Object.values(state.nodes)) {
|
|
8
|
+
state.markDirty(node.id)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function runUndo() {
|
|
13
|
+
useScene.temporal.getState().undo()
|
|
14
|
+
refreshSceneAfterHistoryJump()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function runRedo() {
|
|
18
|
+
useScene.temporal.getState().redo()
|
|
19
|
+
refreshSceneAfterHistoryJump()
|
|
20
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @ts-expect-error — bun:test is provided by the Bun runtime; editor does not
|
|
2
|
+
// depend on @types/bun so the import type is unresolved at compile time.
|
|
3
|
+
import { describe, expect, test } from 'bun:test'
|
|
4
|
+
import {
|
|
5
|
+
type AnyNode,
|
|
6
|
+
type AnyNodeId,
|
|
7
|
+
BuildingNode,
|
|
8
|
+
LevelNode,
|
|
9
|
+
SpawnNode,
|
|
10
|
+
WallNode,
|
|
11
|
+
} from '@pascal-app/core/schema'
|
|
12
|
+
import { buildLevelDuplicateCreateOps } from './level-duplication'
|
|
13
|
+
|
|
14
|
+
describe('buildLevelDuplicateCreateOps', () => {
|
|
15
|
+
test('parents a duplicated bootstrap level back to its building', () => {
|
|
16
|
+
const level = LevelNode.parse({ level: 0, children: [] })
|
|
17
|
+
const building = BuildingNode.parse({ children: [level.id] })
|
|
18
|
+
const wall = WallNode.parse({
|
|
19
|
+
parentId: level.id,
|
|
20
|
+
start: [0, 0],
|
|
21
|
+
end: [4, 0],
|
|
22
|
+
})
|
|
23
|
+
const sourceLevel = { ...level, children: [wall.id] } satisfies LevelNode
|
|
24
|
+
const nodes = {
|
|
25
|
+
[building.id]: building,
|
|
26
|
+
[sourceLevel.id]: sourceLevel,
|
|
27
|
+
[wall.id]: wall,
|
|
28
|
+
} as Record<AnyNodeId, AnyNode>
|
|
29
|
+
|
|
30
|
+
const { createOps, newLevelId } = buildLevelDuplicateCreateOps({
|
|
31
|
+
nodes,
|
|
32
|
+
level: sourceLevel,
|
|
33
|
+
levels: [sourceLevel],
|
|
34
|
+
preset: 'everything',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const levelCreateOp = createOps.find((op) => op.node.id === newLevelId)
|
|
38
|
+
|
|
39
|
+
expect(sourceLevel.parentId).toBeNull()
|
|
40
|
+
expect(levelCreateOp?.parentId).toBe(building.id)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('does not copy spawn points from the source level', () => {
|
|
44
|
+
const building = BuildingNode.parse({})
|
|
45
|
+
const spawn = SpawnNode.parse({ parentId: 'level_source' })
|
|
46
|
+
const level = LevelNode.parse({
|
|
47
|
+
id: 'level_source',
|
|
48
|
+
level: 0,
|
|
49
|
+
parentId: building.id,
|
|
50
|
+
children: [spawn.id],
|
|
51
|
+
})
|
|
52
|
+
const nodes = {
|
|
53
|
+
[building.id]: { ...building, children: [level.id] },
|
|
54
|
+
[level.id]: level,
|
|
55
|
+
[spawn.id]: spawn,
|
|
56
|
+
} as Record<AnyNodeId, AnyNode>
|
|
57
|
+
|
|
58
|
+
const { createOps, newLevelId } = buildLevelDuplicateCreateOps({
|
|
59
|
+
nodes,
|
|
60
|
+
level,
|
|
61
|
+
levels: [level],
|
|
62
|
+
preset: 'everything',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const copiedLevel = createOps.find((op) => op.node.id === newLevelId)?.node as
|
|
66
|
+
| LevelNode
|
|
67
|
+
| undefined
|
|
68
|
+
|
|
69
|
+
expect(createOps.some((op) => op.node.type === 'spawn')).toBe(false)
|
|
70
|
+
expect(copiedLevel?.children).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
})
|