@pascal-app/editor 0.7.0 → 0.8.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 +6 -6
- package/src/components/editor/custom-camera-controls.tsx +2 -1
- package/src/components/editor/editor-layout-v2.tsx +4 -3
- package/src/components/editor/first-person/build-collider-world.ts +5 -7
- package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
- package/src/components/editor/first-person-controls.tsx +11 -11
- package/src/components/editor/floating-action-menu.tsx +0 -0
- package/src/components/editor/floorplan-panel.tsx +44 -37
- package/src/components/editor/index.tsx +68 -53
- package/src/components/editor/selection-manager.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +18 -61
- package/src/components/editor/use-floorplan-background-placement.ts +3 -3
- package/src/components/editor/wall-measurement-label.tsx +0 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-tool.tsx +2 -2
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
- package/src/components/tools/fence/move-fence-tool.tsx +13 -9
- package/src/components/tools/item/move-tool.tsx +3 -6
- package/src/components/tools/item/placement-math.ts +2 -4
- package/src/components/tools/item/placement-strategies.ts +11 -10
- package/src/components/tools/item/use-draft-node.ts +0 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
- package/src/components/tools/roof/move-roof-tool.tsx +7 -2
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/segment-angle.ts +1 -1
- package/src/components/tools/tool-manager.tsx +12 -12
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +0 -0
- package/src/components/tools/wall/wall-tool.tsx +3 -3
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +7 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +35 -86
- package/src/components/ui/action-menu/view-toggles.tsx +19 -31
- package/src/components/ui/command-palette/editor-commands.tsx +6 -4
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +8 -5
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
- package/src/components/ui/level-duplicate-dialog.tsx +3 -5
- package/src/components/ui/panels/ceiling-panel.tsx +2 -3
- package/src/components/ui/panels/column-panel.tsx +62 -18
- package/src/components/ui/panels/door-panel.tsx +272 -265
- package/src/components/ui/panels/fence-panel.tsx +0 -5
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +3 -32
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/panels/roof-panel.tsx +52 -2
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
- package/src/components/ui/panels/slab-panel.tsx +0 -0
- package/src/components/ui/panels/spawn-panel.tsx +10 -4
- package/src/components/ui/panels/stair-panel.tsx +66 -14
- package/src/components/ui/panels/wall-panel.tsx +97 -1
- package/src/components/ui/panels/window-panel.tsx +13 -5
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +10 -0
- package/src/index.tsx +8 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +1 -1
- package/src/lib/material-paint.ts +1 -1
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +2 -2
- package/src/store/use-editor.tsx +27 -59
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/ui/viewer-toolbar.tsx +0 -436
|
@@ -125,7 +125,12 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
|
|
|
125
125
|
return (
|
|
126
126
|
<>
|
|
127
127
|
{measurements.map((measurement) => (
|
|
128
|
-
<g
|
|
128
|
+
<g
|
|
129
|
+
className={className}
|
|
130
|
+
key={measurement.id}
|
|
131
|
+
pointerEvents="none"
|
|
132
|
+
style={{ userSelect: 'none' }}
|
|
133
|
+
>
|
|
129
134
|
<FloorplanMeasurementLine
|
|
130
135
|
dashed={measurement.dashedExtensions ?? true}
|
|
131
136
|
isSelected={measurement.isSelected}
|
|
@@ -396,8 +396,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
|
|
396
396
|
<>
|
|
397
397
|
<polyline
|
|
398
398
|
fill="none"
|
|
399
|
-
points={formatSvgPolygonPoints(arrow.polyline)}
|
|
400
399
|
pointerEvents="none"
|
|
400
|
+
points={formatSvgPolygonPoints(arrow.polyline)}
|
|
401
401
|
stroke={straightAccent}
|
|
402
402
|
strokeWidth="1.15"
|
|
403
403
|
vectorEffect="non-scaling-stroke"
|
|
@@ -411,8 +411,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
|
|
411
411
|
/>
|
|
412
412
|
<polygon
|
|
413
413
|
fill={straightAccent}
|
|
414
|
-
points={formatSvgPolygonPoints(arrow.head)}
|
|
415
414
|
pointerEvents="none"
|
|
415
|
+
points={formatSvgPolygonPoints(arrow.head)}
|
|
416
416
|
/>
|
|
417
417
|
</>
|
|
418
418
|
) : null}
|
|
@@ -438,8 +438,6 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
|
|
438
438
|
}
|
|
439
439
|
: undefined
|
|
440
440
|
}
|
|
441
|
-
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
|
|
442
|
-
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
|
|
443
441
|
onPointerDown={
|
|
444
442
|
canFocusStairs && stairSelected
|
|
445
443
|
? (event) => {
|
|
@@ -449,6 +447,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
|
|
449
447
|
}
|
|
450
448
|
: undefined
|
|
451
449
|
}
|
|
450
|
+
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
|
|
451
|
+
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
|
|
452
452
|
pointerEvents={canSelectStairs ? undefined : 'none'}
|
|
453
453
|
style={canSelectStairs ? { cursor } : undefined}
|
|
454
454
|
>
|
|
@@ -456,8 +456,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
|
|
456
456
|
<polygon
|
|
457
457
|
fill="transparent"
|
|
458
458
|
key={`${stair.id}:hit:${polygonIndex}`}
|
|
459
|
-
points={formatSvgPolygonPoints(polygon)}
|
|
460
459
|
pointerEvents={canSelectStairs ? 'all' : 'none'}
|
|
460
|
+
points={formatSvgPolygonPoints(polygon)}
|
|
461
461
|
stroke="transparent"
|
|
462
462
|
strokeLinejoin="round"
|
|
463
463
|
strokeWidth={hitStrokeWidth}
|
|
@@ -75,7 +75,9 @@ const CeilingSelectionAffordance = ({
|
|
|
75
75
|
ceiling: CeilingNode
|
|
76
76
|
levelId: string
|
|
77
77
|
}) => {
|
|
78
|
-
const [levelObject, setLevelObject] = useState<Object3D | null>(
|
|
78
|
+
const [levelObject, setLevelObject] = useState<Object3D | null>(
|
|
79
|
+
() => sceneRegistry.nodes.get(levelId) ?? null,
|
|
80
|
+
)
|
|
79
81
|
|
|
80
82
|
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
|
|
81
83
|
|
|
@@ -110,11 +112,7 @@ const CeilingSelectionAffordance = ({
|
|
|
110
112
|
return createPortal(
|
|
111
113
|
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
|
112
114
|
{corners.map((corner, index) => (
|
|
113
|
-
<CornerBracket
|
|
114
|
-
ceiling={ceiling}
|
|
115
|
-
corner={corner}
|
|
116
|
-
key={`${ceiling.id}-corner-${index}`}
|
|
117
|
-
/>
|
|
115
|
+
<CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
|
|
118
116
|
))}
|
|
119
117
|
</group>,
|
|
120
118
|
levelObject,
|
|
@@ -210,11 +208,7 @@ const BracketLeg = ({
|
|
|
210
208
|
]
|
|
211
209
|
|
|
212
210
|
return (
|
|
213
|
-
<mesh
|
|
214
|
-
onClick={onClick}
|
|
215
|
-
position={position}
|
|
216
|
-
rotation={[0, angle, 0]}
|
|
217
|
-
>
|
|
211
|
+
<mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
|
|
218
212
|
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
|
219
213
|
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
|
220
214
|
</mesh>
|
|
@@ -234,7 +228,11 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
|
|
|
234
228
|
|
|
235
229
|
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
|
|
236
230
|
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
|
|
237
|
-
const cornerStrength =
|
|
231
|
+
const cornerStrength =
|
|
232
|
+
1 -
|
|
233
|
+
Math.abs(
|
|
234
|
+
incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
|
|
235
|
+
)
|
|
238
236
|
|
|
239
237
|
return {
|
|
240
238
|
corner,
|
|
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
|
|
|
6
6
|
* Imperatively toggles the Three.js visibility of roof objects based on the
|
|
7
7
|
* editor selection — without causing React re-renders in RoofRenderer.
|
|
8
8
|
*
|
|
9
|
-
* When a roof
|
|
9
|
+
* When a roof (or one of its segments) is selected:
|
|
10
10
|
* - merged-roof mesh is hidden
|
|
11
11
|
* - segments-wrapper group is shown (individual segments visible for editing)
|
|
12
12
|
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
|
@@ -68,7 +68,7 @@ export const StairEditSystem = () => {
|
|
|
68
68
|
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
|
69
69
|
const isActive = activeStairIds.has(stairId)
|
|
70
70
|
|
|
71
|
-
if (mergedMesh) mergedMesh.visible = !isActive
|
|
71
|
+
if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
|
|
72
72
|
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
|
|
73
73
|
|
|
74
74
|
if (stairNode?.children?.length) {
|
|
File without changes
|
|
File without changes
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
type CeilingNode,
|
|
6
|
+
emitter,
|
|
7
|
+
type GridEvent,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
4
10
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
11
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
|
+
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
|
6
13
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
7
14
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
15
|
import useEditor from '../../../store/use-editor'
|
|
9
16
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
|
-
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
|
11
17
|
|
|
12
18
|
function snap(value: number) {
|
|
13
19
|
return Math.round(value * 2) / 2
|
|
@@ -202,6 +208,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|
|
202
208
|
transparent
|
|
203
209
|
/>
|
|
204
210
|
</mesh>
|
|
211
|
+
{/* @ts-ignore */}
|
|
205
212
|
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
|
|
206
213
|
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
|
|
207
214
|
</line>
|
|
@@ -83,11 +83,10 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
83
83
|
? event.localPosition[2]
|
|
84
84
|
: snapScalarToGrid(event.localPosition[2], snapStep)
|
|
85
85
|
|
|
86
|
-
const offsetFromMidpoint =
|
|
87
|
-
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)
|
|
86
|
+
const offsetFromMidpoint = -(
|
|
87
|
+
(localX - chord.midpoint.x) * chord.normal.x +
|
|
88
|
+
(localZ - chord.midpoint.y) * chord.normal.y
|
|
89
|
+
)
|
|
91
90
|
const snappedOffset = shiftPressedRef.current
|
|
92
91
|
? offsetFromMidpoint
|
|
93
92
|
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
|
@@ -297,7 +297,7 @@ export const FenceTool: React.FC = () => {
|
|
|
297
297
|
|
|
298
298
|
return (
|
|
299
299
|
<group>
|
|
300
|
-
<CursorSphere
|
|
300
|
+
<CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
|
|
301
301
|
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
|
|
302
302
|
<shapeGeometry />
|
|
303
303
|
<meshBasicMaterial
|
|
@@ -338,7 +338,7 @@ function DraftMeasurementLabel({
|
|
|
338
338
|
}) {
|
|
339
339
|
return (
|
|
340
340
|
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
341
|
-
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px]
|
|
341
|
+
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
|
342
342
|
{label}
|
|
343
343
|
</div>
|
|
344
344
|
</Html>
|
|
@@ -131,10 +131,12 @@ function getLinkedFenceSnapshots(args: {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
if (
|
|
134
|
-
!
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
!(
|
|
135
|
+
samePoint(node.start, originalStart) ||
|
|
136
|
+
samePoint(node.start, originalEnd) ||
|
|
137
|
+
samePoint(node.end, originalStart) ||
|
|
138
|
+
samePoint(node.end, originalEnd)
|
|
139
|
+
)
|
|
138
140
|
) {
|
|
139
141
|
continue
|
|
140
142
|
}
|
|
@@ -303,8 +305,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
303
305
|
}
|
|
304
306
|
|
|
305
307
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
306
|
-
const hasChanged =
|
|
307
|
-
|
|
308
|
+
const hasChanged = !(
|
|
309
|
+
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
|
|
310
|
+
)
|
|
308
311
|
|
|
309
312
|
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
|
310
313
|
wasCommitted = true
|
|
@@ -406,7 +409,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
|
|
406
409
|
>
|
|
407
410
|
<div className="translate-y-10">
|
|
408
411
|
<div
|
|
409
|
-
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px]
|
|
412
|
+
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
|
|
410
413
|
altPressed
|
|
411
414
|
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
|
|
412
415
|
: 'border-border/70 bg-background/90 text-foreground/80'
|
|
@@ -430,7 +433,7 @@ function EndpointAngleLabel({
|
|
|
430
433
|
}) {
|
|
431
434
|
return (
|
|
432
435
|
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
433
|
-
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px]
|
|
436
|
+
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
|
434
437
|
{label}
|
|
435
438
|
</div>
|
|
436
439
|
</Html>
|
|
@@ -17,8 +17,8 @@ import type * as THREE from 'three'
|
|
|
17
17
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
18
18
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
19
19
|
import useEditor from '../../../store/use-editor'
|
|
20
|
-
import { snapFenceDraftPoint } from './fence-drafting'
|
|
21
20
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
21
|
+
import { snapFenceDraftPoint } from './fence-drafting'
|
|
22
22
|
|
|
23
23
|
function samePoint(a: [number, number], b: [number, number]) {
|
|
24
24
|
return a[0] === b[0] && a[1] === b[1]
|
|
@@ -50,10 +50,12 @@ function getLinkedFenceSnapshots(args: {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
if (
|
|
53
|
-
!
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
!(
|
|
54
|
+
samePoint(node.start, originalStart) ||
|
|
55
|
+
samePoint(node.start, originalEnd) ||
|
|
56
|
+
samePoint(node.end, originalStart) ||
|
|
57
|
+
samePoint(node.end, originalEnd)
|
|
58
|
+
)
|
|
57
59
|
) {
|
|
58
60
|
continue
|
|
59
61
|
}
|
|
@@ -164,7 +166,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
164
166
|
}
|
|
165
167
|
}
|
|
166
168
|
|
|
167
|
-
const applyNodePreview = (
|
|
169
|
+
const applyNodePreview = (
|
|
170
|
+
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
|
|
171
|
+
) => {
|
|
168
172
|
useScene.getState().updateNodes(
|
|
169
173
|
updates.map((entry) => ({
|
|
170
174
|
id: entry.id as AnyNodeId,
|
|
@@ -275,13 +279,13 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
275
279
|
emitter.on('tool:cancel', onCancel)
|
|
276
280
|
|
|
277
281
|
return () => {
|
|
278
|
-
if (
|
|
279
|
-
clearPreviewState()
|
|
280
|
-
} else {
|
|
282
|
+
if (wasCommitted) {
|
|
281
283
|
useLiveTransforms.getState().clear(nodeId)
|
|
282
284
|
for (const linkedFence of linkedOriginalsRef.current) {
|
|
283
285
|
useLiveTransforms.getState().clear(linkedFence.id)
|
|
284
286
|
}
|
|
287
|
+
} else {
|
|
288
|
+
clearPreviewState()
|
|
285
289
|
}
|
|
286
290
|
useScene.temporal.getState().resume()
|
|
287
291
|
emitter.off('grid:move', onGridMove)
|
|
@@ -90,9 +90,7 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
|
|
90
90
|
return <>{cursor}</>
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export const MoveTool: React.FC
|
|
94
|
-
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
|
|
95
|
-
}> = ({ onSpawnMoved }) => {
|
|
93
|
+
export const MoveTool: React.FC = () => {
|
|
96
94
|
const movingNode = useEditor((state) => state.movingNode)
|
|
97
95
|
|
|
98
96
|
if (!movingNode) return null
|
|
@@ -100,15 +98,14 @@ export const MoveTool: React.FC<{
|
|
|
100
98
|
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
|
101
99
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
|
102
100
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
|
103
|
-
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
|
104
101
|
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
|
105
102
|
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
|
106
103
|
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
|
107
104
|
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
|
105
|
+
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
|
108
106
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
|
109
107
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
|
110
|
-
if (movingNode.type === 'spawn')
|
|
111
|
-
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
|
|
108
|
+
if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
|
|
112
109
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
|
113
110
|
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
|
114
111
|
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
|
@@ -10,9 +10,7 @@ function positiveModulo(value: number, divisor: number): number {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Snaps a position to
|
|
14
|
-
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
|
|
15
|
-
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
|
|
13
|
+
* Snaps a position to the active grid step, aligning item edges to grid lines.
|
|
16
14
|
*/
|
|
17
15
|
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
|
|
18
16
|
const halfDim = dimension / 2
|
|
@@ -21,7 +19,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
|
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
|
-
* Snap a value to
|
|
22
|
+
* Snap a value to the active grid step (used for wall-local positions).
|
|
25
23
|
*/
|
|
26
24
|
export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
|
27
25
|
return Math.round(value / step) * step
|
|
@@ -506,21 +506,22 @@ export const itemSurfaceStrategy = {
|
|
|
506
506
|
|
|
507
507
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
508
508
|
|
|
509
|
+
// Counter-rotate so the draft's world Y rotation stays continuous when
|
|
510
|
+
// the user drags onto a rotated surface item. The cursor wireframe
|
|
511
|
+
// already shows the user's intended world rotation; we just need to
|
|
512
|
+
// store the right local value relative to the new parent.
|
|
513
|
+
const surfaceQuat = new Quaternion()
|
|
514
|
+
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
515
|
+
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
516
|
+
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
|
|
517
|
+
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
|
518
|
+
|
|
509
519
|
return {
|
|
510
520
|
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
|
511
521
|
nodeUpdate: {
|
|
512
522
|
position: [x, y, z],
|
|
513
523
|
parentId: surfaceItem.id,
|
|
514
|
-
rotation: [
|
|
515
|
-
(ctx.draftItem?.rotation ?? [0, 0, 0])[0],
|
|
516
|
-
(() => {
|
|
517
|
-
const surfaceQuat = new Quaternion()
|
|
518
|
-
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
519
|
-
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
520
|
-
return ctx.currentCursorRotationY - surfaceWorldY
|
|
521
|
-
})(),
|
|
522
|
-
(ctx.draftItem?.rotation ?? [0, 0, 0])[2],
|
|
523
|
-
] as [number, number, number],
|
|
524
|
+
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
|
|
524
525
|
},
|
|
525
526
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
526
527
|
gridPosition: [x, y, z],
|
|
@@ -130,7 +130,6 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
130
130
|
useScene.getState().updateNode(draft.id, {
|
|
131
131
|
position: updateProps.position ?? draft.position,
|
|
132
132
|
rotation: updateProps.rotation ?? draft.rotation,
|
|
133
|
-
scale: updateProps.scale ?? draft.scale,
|
|
134
133
|
side: updateProps.side ?? draft.side,
|
|
135
134
|
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
|
136
135
|
parentId: parentId as string,
|
|
@@ -20,15 +20,12 @@ import { Html } from '@react-three/drei'
|
|
|
20
20
|
import { useFrame } from '@react-three/fiber'
|
|
21
21
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
22
22
|
import {
|
|
23
|
-
Box3,
|
|
24
23
|
BufferGeometry,
|
|
25
24
|
Euler,
|
|
26
25
|
Float32BufferAttribute,
|
|
27
26
|
type Group,
|
|
28
27
|
type LineSegments,
|
|
29
|
-
Matrix4,
|
|
30
28
|
type Mesh,
|
|
31
|
-
type Object3D,
|
|
32
29
|
PlaneGeometry,
|
|
33
30
|
Quaternion,
|
|
34
31
|
Vector3,
|
|
@@ -117,79 +114,6 @@ function expandBoundsToGrid(
|
|
|
117
114
|
}
|
|
118
115
|
}
|
|
119
116
|
|
|
120
|
-
function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
|
|
121
|
-
if (!object) return null
|
|
122
|
-
|
|
123
|
-
object.updateWorldMatrix(true, true)
|
|
124
|
-
|
|
125
|
-
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
|
|
126
|
-
const localMatrix = new Matrix4()
|
|
127
|
-
const localBounds = new Box3()
|
|
128
|
-
const scratchBounds = new Box3()
|
|
129
|
-
const hasBounds = { current: false }
|
|
130
|
-
const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
|
|
131
|
-
|
|
132
|
-
const expandBounds = (child: Object3D) => {
|
|
133
|
-
if (child !== object && registeredNodeObjects.has(child)) {
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const mesh = child as Object3D & {
|
|
138
|
-
isMesh?: boolean
|
|
139
|
-
name?: string
|
|
140
|
-
geometry?: {
|
|
141
|
-
boundingBox: Box3 | null
|
|
142
|
-
computeBoundingBox?: () => void
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
|
|
147
|
-
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
|
|
148
|
-
mesh.geometry.computeBoundingBox()
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (mesh.geometry.boundingBox) {
|
|
152
|
-
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
|
|
153
|
-
scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
|
|
154
|
-
if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
|
|
155
|
-
if (!hasBounds.current) {
|
|
156
|
-
localBounds.copy(scratchBounds)
|
|
157
|
-
hasBounds.current = true
|
|
158
|
-
} else {
|
|
159
|
-
localBounds.union(scratchBounds)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const grandchild of child.children) {
|
|
166
|
-
expandBounds(grandchild)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
for (const child of object.children) {
|
|
171
|
-
expandBounds(child)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (!hasBounds.current) return null
|
|
175
|
-
|
|
176
|
-
const size = new Vector3()
|
|
177
|
-
const center = new Vector3()
|
|
178
|
-
localBounds.getSize(size)
|
|
179
|
-
localBounds.getCenter(center)
|
|
180
|
-
|
|
181
|
-
if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
|
|
182
|
-
return null
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
|
|
187
|
-
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
|
|
188
|
-
dimensions: [size.x, size.y, size.z],
|
|
189
|
-
center: [center.x, center.y, center.z],
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
117
|
function getFallbackPreviewBounds(
|
|
194
118
|
item: import('@pascal-app/core').ItemNode | null,
|
|
195
119
|
asset: AssetInput,
|
|
@@ -366,7 +290,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
366
290
|
)
|
|
367
291
|
const shiftFreeRef = useRef(false)
|
|
368
292
|
const previewBoundsSignatureRef = useRef<string | null>(null)
|
|
369
|
-
const meshPreviewAppliedRef = useRef(false)
|
|
370
293
|
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
|
371
294
|
|
|
372
295
|
// Store config callbacks in refs to avoid re-running effect when they change
|
|
@@ -503,7 +426,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
503
426
|
useEffect(() => {
|
|
504
427
|
if (!asset) return
|
|
505
428
|
useScene.temporal.getState().pause()
|
|
506
|
-
meshPreviewAppliedRef.current = false
|
|
507
429
|
|
|
508
430
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
|
509
431
|
|
|
@@ -1264,21 +1186,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1264
1186
|
window.addEventListener('contextmenu', onContextMenu)
|
|
1265
1187
|
|
|
1266
1188
|
// ---- Bounding box geometry ----
|
|
1189
|
+
// Always derive the wireframe from `asset.dimensions × scale` rather than
|
|
1190
|
+
// the rendered mesh bounds. Asset dimensions describe the item's footprint
|
|
1191
|
+
// (e.g. only the trunk for a palm tree), while the mesh bbox would include
|
|
1192
|
+
// foliage or other visual overhang the snap logic intentionally ignores.
|
|
1267
1193
|
|
|
1268
1194
|
const draft = draftNode.current
|
|
1269
|
-
const
|
|
1195
|
+
const previewBounds = expandBoundsToGrid(
|
|
1270
1196
|
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
|
1271
1197
|
asset.attachTo,
|
|
1272
1198
|
gridSnapStep,
|
|
1273
1199
|
)
|
|
1274
|
-
const previewBounds = draft
|
|
1275
|
-
? expandBoundsToGrid(
|
|
1276
|
-
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
|
|
1277
|
-
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
|
1278
|
-
asset.attachTo,
|
|
1279
|
-
gridSnapStep,
|
|
1280
|
-
)
|
|
1281
|
-
: fallbackBounds
|
|
1282
1200
|
updatePreviewGeometry(previewBounds)
|
|
1283
1201
|
updateDimensionGuides(previewBounds)
|
|
1284
1202
|
|
|
@@ -1324,7 +1242,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1324
1242
|
|
|
1325
1243
|
return () => {
|
|
1326
1244
|
tearingDown = true
|
|
1327
|
-
meshPreviewAppliedRef.current = false
|
|
1328
1245
|
unsubDraftWatch()
|
|
1329
1246
|
// Clear live transform for any remaining draft
|
|
1330
1247
|
if (draftNode.current) {
|
|
@@ -1358,17 +1275,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1358
1275
|
useEffect(() => {
|
|
1359
1276
|
if (!asset) return
|
|
1360
1277
|
const draft = draftNode.current
|
|
1361
|
-
const
|
|
1278
|
+
const previewBounds = expandBoundsToGrid(
|
|
1362
1279
|
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
|
1363
1280
|
asset.attachTo,
|
|
1364
1281
|
gridSnapStep,
|
|
1365
1282
|
)
|
|
1366
|
-
const meshBounds = draft
|
|
1367
|
-
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
|
|
1368
|
-
: null
|
|
1369
|
-
const previewBounds = meshBounds
|
|
1370
|
-
? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
|
|
1371
|
-
: fallbackBounds
|
|
1372
1283
|
updatePreviewGeometry(previewBounds)
|
|
1373
1284
|
updateDimensionGuides(previewBounds)
|
|
1374
1285
|
}, [gridSnapStep, asset, draftNode])
|
|
@@ -1388,19 +1299,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1388
1299
|
if (!draftNode.current) return
|
|
1389
1300
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
|
1390
1301
|
if (!mesh) return
|
|
1391
|
-
if (!meshPreviewAppliedRef.current) {
|
|
1392
|
-
const previewBounds = getPreviewBoundsFromObject(mesh)
|
|
1393
|
-
if (previewBounds) {
|
|
1394
|
-
const expandedBounds = expandBoundsToGrid(
|
|
1395
|
-
previewBounds,
|
|
1396
|
-
asset.attachTo,
|
|
1397
|
-
useEditor.getState().gridSnapStep,
|
|
1398
|
-
)
|
|
1399
|
-
updatePreviewGeometry(expandedBounds)
|
|
1400
|
-
updateDimensionGuides(expandedBounds)
|
|
1401
|
-
meshPreviewAppliedRef.current = true
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
1302
|
|
|
1405
1303
|
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
|
1406
1304
|
if (asset.attachTo && placementState.current.surface === 'floor') {
|
|
@@ -1490,22 +1388,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1490
1388
|
const measurementContent = (
|
|
1491
1389
|
<>
|
|
1492
1390
|
<lineSegments
|
|
1493
|
-
layers={EDITOR_LAYER}
|
|
1494
1391
|
geometry={initialWidthGuideGeometry}
|
|
1392
|
+
layers={EDITOR_LAYER}
|
|
1495
1393
|
material={measurementMaterial}
|
|
1496
1394
|
ref={measurementWidthRef}
|
|
1497
1395
|
renderOrder={998}
|
|
1498
1396
|
/>
|
|
1499
1397
|
<lineSegments
|
|
1500
|
-
layers={EDITOR_LAYER}
|
|
1501
1398
|
geometry={initialDepthGuideGeometry}
|
|
1399
|
+
layers={EDITOR_LAYER}
|
|
1502
1400
|
material={measurementMaterial}
|
|
1503
1401
|
ref={measurementDepthRef}
|
|
1504
1402
|
renderOrder={998}
|
|
1505
1403
|
/>
|
|
1506
1404
|
<lineSegments
|
|
1507
|
-
layers={EDITOR_LAYER}
|
|
1508
1405
|
geometry={initialHeightGuideGeometry}
|
|
1406
|
+
layers={EDITOR_LAYER}
|
|
1509
1407
|
material={measurementMaterial}
|
|
1510
1408
|
ref={measurementHeightRef}
|
|
1511
1409
|
renderOrder={998}
|
|
@@ -158,7 +158,9 @@ export const MoveRoofTool: React.FC<{
|
|
|
158
158
|
|
|
159
159
|
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
|
|
160
160
|
if (buildingObj) {
|
|
161
|
-
const worldPoint = buildingObj.localToWorld(
|
|
161
|
+
const worldPoint = buildingObj.localToWorld(
|
|
162
|
+
new THREE.Vector3(localPoint[0], y, localPoint[1]),
|
|
163
|
+
)
|
|
162
164
|
return [worldPoint.x, worldPoint.y, worldPoint.z]
|
|
163
165
|
}
|
|
164
166
|
|
|
@@ -211,7 +213,10 @@ export const MoveRoofTool: React.FC<{
|
|
|
211
213
|
})
|
|
212
214
|
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
|
213
215
|
|
|
214
|
-
if (
|
|
216
|
+
if (
|
|
217
|
+
previousGridPosRef.current &&
|
|
218
|
+
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
|
219
|
+
) {
|
|
215
220
|
sfxEmitter.emit('sfx:grid-snap')
|
|
216
221
|
}
|
|
217
222
|
|