@pascal-app/editor 0.9.2 → 1.0.0-beta.1
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 +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -95,6 +95,10 @@ function getFloorplanStairStepCount(stair: StairNode, minimum: number) {
|
|
|
95
95
|
return Math.max(minimum, Math.round(stair.stepCount ?? 10))
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
function getFloorplanStairBreakStep(stepCount: number) {
|
|
99
|
+
return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * 0.68))
|
|
100
|
+
}
|
|
101
|
+
|
|
98
102
|
function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
|
|
99
103
|
if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') {
|
|
100
104
|
return 0
|
|
@@ -244,14 +248,15 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
|
|
244
248
|
const angle = sectorStartAngle + stepSweep * index
|
|
245
249
|
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
|
|
246
250
|
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
|
|
247
|
-
|
|
251
|
+
if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
248
254
|
|
|
249
255
|
return (
|
|
250
256
|
<line
|
|
251
257
|
key={`${stair.id}:spiral-step:${index}`}
|
|
252
258
|
pointerEvents="none"
|
|
253
259
|
stroke={index === stepCount ? curvedAccent : curvedStroke}
|
|
254
|
-
strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined}
|
|
255
260
|
strokeWidth={index === stepCount ? '1.8' : '1.15'}
|
|
256
261
|
vectorEffect="non-scaling-stroke"
|
|
257
262
|
x1={toSvgX(innerPoint.x)}
|
|
@@ -330,6 +335,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
|
|
330
335
|
const angle = sectorStartAngle + stepSweep * index
|
|
331
336
|
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
|
|
332
337
|
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
|
|
338
|
+
if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
333
341
|
|
|
334
342
|
return (
|
|
335
343
|
<line
|
|
@@ -397,14 +405,16 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
|
|
397
405
|
strokeWidth={isSelectionActive ? '2' : '1.35'}
|
|
398
406
|
vectorEffect="non-scaling-stroke"
|
|
399
407
|
/>
|
|
400
|
-
{treadBars
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
+
{treadBars
|
|
409
|
+
.slice(0, Math.max(0, getFloorplanStairBreakStep(segment.stepCount) - 1))
|
|
410
|
+
.map((treadBar, treadIndex) => (
|
|
411
|
+
<polygon
|
|
412
|
+
fill={straightTread}
|
|
413
|
+
key={`${segment.id}:tread:${treadIndex}`}
|
|
414
|
+
pointerEvents="none"
|
|
415
|
+
points={segment.segmentType === 'landing' ? '' : treadBar.points}
|
|
416
|
+
/>
|
|
417
|
+
))}
|
|
408
418
|
</g>
|
|
409
419
|
))}
|
|
410
420
|
{arrow?.polyline && arrow.polyline.length >= 2 ? (
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
type CeilingNode,
|
|
5
5
|
emitter,
|
|
6
|
+
resolveCeilingHeight,
|
|
6
7
|
resolveLevelId,
|
|
7
8
|
sceneRegistry,
|
|
8
9
|
snapPointToGrid,
|
|
@@ -151,6 +152,9 @@ const CeilingSelectionAffordance = ({
|
|
|
151
152
|
() => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
|
|
152
153
|
[ceiling, liveOverride],
|
|
153
154
|
)
|
|
155
|
+
// Explicit height when stored, else the live level-top bound the ceiling
|
|
156
|
+
// follows (primitive selector — re-render-safe).
|
|
157
|
+
const resolvedHeight = useScene((s) => resolveCeilingHeight(effectiveCeiling, s.nodes))
|
|
154
158
|
const [levelObject, setLevelObject] = useState<Object3D | null>(
|
|
155
159
|
() => sceneRegistry.nodes.get(levelId) ?? null,
|
|
156
160
|
)
|
|
@@ -221,7 +225,7 @@ const CeilingSelectionAffordance = ({
|
|
|
221
225
|
)
|
|
222
226
|
raycasterRef.current.setFromCamera(ndcRef.current, camera)
|
|
223
227
|
|
|
224
|
-
planePointRef.current.set(0,
|
|
228
|
+
planePointRef.current.set(0, resolvedHeight + BRACKET_Y_OFFSET, 0)
|
|
225
229
|
levelObject.localToWorld(planePointRef.current)
|
|
226
230
|
|
|
227
231
|
planeOriginRef.current.set(0, 0, 0)
|
|
@@ -238,7 +242,7 @@ const CeilingSelectionAffordance = ({
|
|
|
238
242
|
levelObject.worldToLocal(localIntersectionRef.current)
|
|
239
243
|
return [localIntersectionRef.current.x, localIntersectionRef.current.z]
|
|
240
244
|
},
|
|
241
|
-
[camera,
|
|
245
|
+
[camera, resolvedHeight, gl.domElement, levelObject],
|
|
242
246
|
)
|
|
243
247
|
|
|
244
248
|
const handleCornerPointerDown = useCallback(
|
|
@@ -438,10 +442,7 @@ const CeilingSelectionAffordance = ({
|
|
|
438
442
|
if (!levelObject || corners.length === 0) return null
|
|
439
443
|
|
|
440
444
|
return createPortal(
|
|
441
|
-
<group
|
|
442
|
-
position={[0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}
|
|
443
|
-
ref={bracketsRootRef}
|
|
444
|
-
>
|
|
445
|
+
<group position={[0, resolvedHeight + BRACKET_Y_OFFSET, 0]} ref={bracketsRootRef}>
|
|
445
446
|
{corners.map((corner, index) => (
|
|
446
447
|
<CornerBracket
|
|
447
448
|
ceiling={effectiveCeiling}
|
|
@@ -507,7 +508,11 @@ const CornerBracket = ({
|
|
|
507
508
|
node: ceiling,
|
|
508
509
|
nativeEvent: e.nativeEvent,
|
|
509
510
|
localPosition: [0, 0, 0],
|
|
510
|
-
position: [
|
|
511
|
+
position: [
|
|
512
|
+
corner.corner[0],
|
|
513
|
+
resolveCeilingHeight(ceiling, useScene.getState().nodes),
|
|
514
|
+
corner.corner[1],
|
|
515
|
+
],
|
|
511
516
|
stopPropagation: () => e.stopPropagation(),
|
|
512
517
|
viaHandle: true,
|
|
513
518
|
})
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getWallCurveFrameAt,
|
|
6
6
|
getWallCurveLength,
|
|
7
7
|
isCurvedWall,
|
|
8
|
+
resolveFenceSupportSlabPatch,
|
|
8
9
|
snapPointAlongAngleRay,
|
|
9
10
|
useScene,
|
|
10
11
|
type WallNode,
|
|
@@ -187,9 +188,22 @@ export function snapFenceDraftPoint(args: {
|
|
|
187
188
|
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
export type FenceCommitOptions = {
|
|
192
|
+
/**
|
|
193
|
+
* Pointer-decided support cap (level-local Y) from
|
|
194
|
+
* `resolvePointerSupportSurface` — the 3D tool passes the elevation of
|
|
195
|
+
* the surface the commit click actually aimed at, so a fence drawn on a
|
|
196
|
+
* deck top persists the deck as its lift host while one drawn at the
|
|
197
|
+
* floor underneath stays grounded. Omitted by 2D floor-plan commits (no
|
|
198
|
+
* camera ray): those keep the uncapped max election.
|
|
199
|
+
*/
|
|
200
|
+
supportCap?: number | null
|
|
201
|
+
}
|
|
202
|
+
|
|
190
203
|
export function createFenceOnCurrentLevel(
|
|
191
204
|
start: FencePlanPoint,
|
|
192
205
|
end: FencePlanPoint,
|
|
206
|
+
options?: FenceCommitOptions,
|
|
193
207
|
): FenceNode | null {
|
|
194
208
|
const currentLevelId = useViewer.getState().selection.levelId
|
|
195
209
|
const { createNode, nodes } = useScene.getState()
|
|
@@ -209,6 +223,13 @@ export function createFenceOnCurrentLevel(
|
|
|
209
223
|
start,
|
|
210
224
|
end,
|
|
211
225
|
})
|
|
226
|
+
// Fences run no per-frame support election — the persisted host IS the
|
|
227
|
+
// lift (absent = level floor), so elect it at commit, pointer-capped.
|
|
228
|
+
fence.supportSlabId = resolveFenceSupportSlabPatch(
|
|
229
|
+
{ ...fence, parentId: currentLevelId },
|
|
230
|
+
nodes,
|
|
231
|
+
{ maxElevation: options?.supportCap ?? null },
|
|
232
|
+
).supportSlabId
|
|
212
233
|
|
|
213
234
|
createNode(fence, currentLevelId)
|
|
214
235
|
sfxEmitter.emit('sfx:structure-build')
|
|
@@ -225,6 +246,7 @@ export function createFenceOnCurrentLevel(
|
|
|
225
246
|
export function createSplineFenceOnCurrentLevel(
|
|
226
247
|
path: FencePlanPoint[],
|
|
227
248
|
tangents = getTwoPointFenceCurveTangents(path),
|
|
249
|
+
options?: FenceCommitOptions,
|
|
228
250
|
): FenceNode | null {
|
|
229
251
|
const currentLevelId = useViewer.getState().selection.levelId
|
|
230
252
|
const { createNode, nodes } = useScene.getState()
|
|
@@ -250,6 +272,11 @@ export function createSplineFenceOnCurrentLevel(
|
|
|
250
272
|
path,
|
|
251
273
|
tangents,
|
|
252
274
|
})
|
|
275
|
+
fence.supportSlabId = resolveFenceSupportSlabPatch(
|
|
276
|
+
{ ...fence, parentId: currentLevelId },
|
|
277
|
+
nodes,
|
|
278
|
+
{ maxElevation: options?.supportCap ?? null },
|
|
279
|
+
).supportSlabId
|
|
253
280
|
|
|
254
281
|
createNode(fence, currentLevelId)
|
|
255
282
|
sfxEmitter.emit('sfx:structure-build')
|
|
@@ -2,12 +2,14 @@ import {
|
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
type AssetInput,
|
|
4
4
|
ItemNode,
|
|
5
|
+
resolveSupportSlabPatch,
|
|
5
6
|
sceneRegistry,
|
|
6
7
|
useScene,
|
|
7
8
|
} from '@pascal-app/core'
|
|
8
9
|
import { useViewer } from '@pascal-app/viewer'
|
|
9
10
|
import { useCallback, useMemo, useRef } from 'react'
|
|
10
11
|
import type { Vector3 } from 'three'
|
|
12
|
+
import usePlacementPreview from '../../../store/use-placement-preview'
|
|
11
13
|
import { stripTransient } from './placement-math'
|
|
12
14
|
|
|
13
15
|
interface OriginalState {
|
|
@@ -38,8 +40,14 @@ export interface DraftNodeHandle {
|
|
|
38
40
|
) => ItemNode | null
|
|
39
41
|
/** Take ownership of an existing scene node as the draft (for move mode). */
|
|
40
42
|
adopt: (node: ItemNode) => void
|
|
41
|
-
/** Commit the current draft. Create mode: delete+recreate. Move mode: update in place.
|
|
42
|
-
|
|
43
|
+
/** Commit the current draft. Create mode: delete+recreate. Move mode: update in place.
|
|
44
|
+
* `supportElevationCap` (floor commits) is the pointer-decided surface
|
|
45
|
+
* elevation — it caps the persisted `supportSlabId` election so the
|
|
46
|
+
* commit lands on the surface the cursor pointed at. */
|
|
47
|
+
commit: (
|
|
48
|
+
finalUpdate: Partial<ItemNode>,
|
|
49
|
+
options?: { supportElevationCap?: number | null },
|
|
50
|
+
) => string | null
|
|
43
51
|
/** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */
|
|
44
52
|
destroy: () => void
|
|
45
53
|
}
|
|
@@ -80,6 +88,9 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
80
88
|
})
|
|
81
89
|
|
|
82
90
|
useScene.getState().createNode(node, currentLevelId)
|
|
91
|
+
usePlacementPreview
|
|
92
|
+
.getState()
|
|
93
|
+
.set(node, useScene.getState().nodes[currentLevelId as AnyNodeId] ?? null)
|
|
83
94
|
draftRef.current = node
|
|
84
95
|
adoptedRef.current = false
|
|
85
96
|
originalStateRef.current = null
|
|
@@ -112,100 +123,142 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
112
123
|
useScene.getState().updateNode(node.id, {
|
|
113
124
|
metadata: { ...meta, isTransient: true },
|
|
114
125
|
})
|
|
126
|
+
usePlacementPreview
|
|
127
|
+
.getState()
|
|
128
|
+
.set(
|
|
129
|
+
node,
|
|
130
|
+
node.parentId ? (useScene.getState().nodes[node.parentId as AnyNodeId] ?? null) : null,
|
|
131
|
+
)
|
|
115
132
|
}, [])
|
|
116
133
|
|
|
117
|
-
const commit = useCallback(
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
const commit = useCallback(
|
|
135
|
+
(
|
|
136
|
+
finalUpdate: Partial<ItemNode>,
|
|
137
|
+
options?: { supportElevationCap?: number | null },
|
|
138
|
+
): string | null => {
|
|
139
|
+
const draft = draftRef.current
|
|
140
|
+
if (!draft) return null
|
|
141
|
+
|
|
142
|
+
if (adoptedRef.current) {
|
|
143
|
+
// Move mode: update in place (single undoable action)
|
|
144
|
+
const { parentId: newParentId, ...updateProps } = finalUpdate
|
|
145
|
+
const parentId =
|
|
146
|
+
newParentId ??
|
|
147
|
+
originalStateRef.current?.parentId ??
|
|
148
|
+
useViewer.getState().selection.levelId
|
|
149
|
+
const original = originalStateRef.current!
|
|
150
|
+
|
|
151
|
+
// Restore original state while paused — so the undo baseline is clean
|
|
152
|
+
useScene.getState().updateNode(draft.id, {
|
|
153
|
+
position: original.position,
|
|
154
|
+
rotation: original.rotation,
|
|
155
|
+
side: original.side,
|
|
156
|
+
parentId: original.parentId,
|
|
157
|
+
roofSegmentId: original.roofSegmentId,
|
|
158
|
+
roofFace: original.roofFace,
|
|
159
|
+
metadata: original.metadata,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Resume → tracked update (undo reverts to original)
|
|
163
|
+
useScene.temporal.getState().resume()
|
|
164
|
+
|
|
165
|
+
const effectiveNode = ItemNode.parse({
|
|
166
|
+
...draft,
|
|
167
|
+
...updateProps,
|
|
168
|
+
parentId,
|
|
169
|
+
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
useScene.getState().updateNode(draft.id, {
|
|
173
|
+
position: updateProps.position ?? draft.position,
|
|
174
|
+
rotation: updateProps.rotation ?? draft.rotation,
|
|
175
|
+
side: updateProps.side ?? draft.side,
|
|
176
|
+
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
|
177
|
+
parentId: parentId as string,
|
|
178
|
+
// Forward the roof host explicitly: strategies set it on every
|
|
179
|
+
// commit (segment id on a roof face, undefined elsewhere), and
|
|
180
|
+
// dropping it here strands the item in the roof frame without
|
|
181
|
+
// the segment transform.
|
|
182
|
+
roofSegmentId: updateProps.roofSegmentId,
|
|
183
|
+
roofFace: updateProps.roofFace,
|
|
184
|
+
// Only when the strategy decided about wallId (roof commits clear
|
|
185
|
+
// it) — floor/ceiling commits never managed the field.
|
|
186
|
+
...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
|
|
187
|
+
...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
|
|
188
|
+
maxElevation: options?.supportElevationCap,
|
|
189
|
+
}),
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
useScene.temporal.getState().pause()
|
|
193
|
+
|
|
194
|
+
const id = draft.id
|
|
195
|
+
if (usePlacementPreview.getState().node?.id === id) {
|
|
196
|
+
usePlacementPreview.getState().clear()
|
|
197
|
+
}
|
|
198
|
+
draftRef.current = null
|
|
199
|
+
adoptedRef.current = false
|
|
200
|
+
originalStateRef.current = null
|
|
201
|
+
return id
|
|
202
|
+
}
|
|
120
203
|
|
|
121
|
-
|
|
122
|
-
// Move mode: update in place (single undoable action)
|
|
204
|
+
// Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause
|
|
123
205
|
const { parentId: newParentId, ...updateProps } = finalUpdate
|
|
124
|
-
const parentId =
|
|
125
|
-
|
|
126
|
-
const original = originalStateRef.current!
|
|
206
|
+
const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
|
|
207
|
+
if (!parentId) return null
|
|
127
208
|
|
|
128
|
-
//
|
|
129
|
-
useScene.getState().
|
|
130
|
-
|
|
131
|
-
rotation: original.rotation,
|
|
132
|
-
side: original.side,
|
|
133
|
-
parentId: original.parentId,
|
|
134
|
-
roofSegmentId: original.roofSegmentId,
|
|
135
|
-
roofFace: original.roofFace,
|
|
136
|
-
metadata: original.metadata,
|
|
137
|
-
})
|
|
209
|
+
// Delete draft while paused (invisible to undo)
|
|
210
|
+
useScene.getState().deleteNode(draft.id)
|
|
211
|
+
draftRef.current = null
|
|
138
212
|
|
|
139
|
-
//
|
|
213
|
+
// Briefly resume → create fresh node (the single undoable action)
|
|
140
214
|
useScene.temporal.getState().resume()
|
|
141
215
|
|
|
142
|
-
|
|
216
|
+
const finalNode = ItemNode.parse({
|
|
217
|
+
name: draft.name,
|
|
218
|
+
asset: draft.asset,
|
|
143
219
|
position: updateProps.position ?? draft.position,
|
|
144
220
|
rotation: updateProps.rotation ?? draft.rotation,
|
|
221
|
+
scale: updateProps.scale ?? draft.scale,
|
|
145
222
|
side: updateProps.side ?? draft.side,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
// dropping it here strands the item in the roof frame without
|
|
151
|
-
// the segment transform.
|
|
223
|
+
// Carry painted slot overrides so a duplicated item keeps its materials.
|
|
224
|
+
...(draft.slots ? { slots: draft.slots } : {}),
|
|
225
|
+
// Roof host — see the move-mode commit above for why this must be
|
|
226
|
+
// forwarded explicitly.
|
|
152
227
|
roofSegmentId: updateProps.roofSegmentId,
|
|
153
228
|
roofFace: updateProps.roofFace,
|
|
154
|
-
// Only when the strategy decided about wallId (roof commits clear
|
|
155
|
-
// it) — floor/ceiling commits never managed the field.
|
|
156
229
|
...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
|
|
230
|
+
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
|
231
|
+
parentId,
|
|
232
|
+
})
|
|
233
|
+
const nodes = useScene.getState().nodes
|
|
234
|
+
const committedNode = ItemNode.parse({
|
|
235
|
+
...finalNode,
|
|
236
|
+
...resolveSupportSlabPatch(
|
|
237
|
+
finalNode,
|
|
238
|
+
{ ...nodes, [finalNode.id]: finalNode },
|
|
239
|
+
{ maxElevation: options?.supportElevationCap },
|
|
240
|
+
),
|
|
157
241
|
})
|
|
242
|
+
useScene.getState().createNode(committedNode, parentId)
|
|
243
|
+
if (usePlacementPreview.getState().node?.id === draft.id) {
|
|
244
|
+
usePlacementPreview.getState().clear()
|
|
245
|
+
}
|
|
158
246
|
|
|
247
|
+
// Re-pause for next draft cycle
|
|
159
248
|
useScene.temporal.getState().pause()
|
|
160
249
|
|
|
161
|
-
const id = draft.id
|
|
162
|
-
draftRef.current = null
|
|
163
250
|
adoptedRef.current = false
|
|
164
251
|
originalStateRef.current = null
|
|
165
|
-
return id
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const { parentId: newParentId, ...updateProps } = finalUpdate
|
|
170
|
-
const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
|
|
171
|
-
if (!parentId) return null
|
|
172
|
-
|
|
173
|
-
// Delete draft while paused (invisible to undo)
|
|
174
|
-
useScene.getState().deleteNode(draft.id)
|
|
175
|
-
draftRef.current = null
|
|
176
|
-
|
|
177
|
-
// Briefly resume → create fresh node (the single undoable action)
|
|
178
|
-
useScene.temporal.getState().resume()
|
|
179
|
-
|
|
180
|
-
const finalNode = ItemNode.parse({
|
|
181
|
-
name: draft.name,
|
|
182
|
-
asset: draft.asset,
|
|
183
|
-
position: updateProps.position ?? draft.position,
|
|
184
|
-
rotation: updateProps.rotation ?? draft.rotation,
|
|
185
|
-
scale: updateProps.scale ?? draft.scale,
|
|
186
|
-
side: updateProps.side ?? draft.side,
|
|
187
|
-
// Carry painted slot overrides so a duplicated item keeps its materials.
|
|
188
|
-
...(draft.slots ? { slots: draft.slots } : {}),
|
|
189
|
-
// Roof host — see the move-mode commit above for why this must be
|
|
190
|
-
// forwarded explicitly.
|
|
191
|
-
roofSegmentId: updateProps.roofSegmentId,
|
|
192
|
-
roofFace: updateProps.roofFace,
|
|
193
|
-
...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
|
|
194
|
-
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
|
195
|
-
})
|
|
196
|
-
useScene.getState().createNode(finalNode, parentId)
|
|
197
|
-
|
|
198
|
-
// Re-pause for next draft cycle
|
|
199
|
-
useScene.temporal.getState().pause()
|
|
200
|
-
|
|
201
|
-
adoptedRef.current = false
|
|
202
|
-
originalStateRef.current = null
|
|
203
|
-
return finalNode.id
|
|
204
|
-
}, [])
|
|
252
|
+
return committedNode.id
|
|
253
|
+
},
|
|
254
|
+
[],
|
|
255
|
+
)
|
|
205
256
|
|
|
206
257
|
const destroy = useCallback(() => {
|
|
207
258
|
if (!draftRef.current) return
|
|
208
259
|
|
|
260
|
+
const draftId = draftRef.current.id
|
|
261
|
+
|
|
209
262
|
if (adoptedRef.current && originalStateRef.current) {
|
|
210
263
|
// Move mode: restore original state instead of deleting — but only
|
|
211
264
|
// if no other system has already committed a new position for this
|
|
@@ -227,6 +280,9 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
227
280
|
draftRef.current = null
|
|
228
281
|
adoptedRef.current = false
|
|
229
282
|
originalStateRef.current = null
|
|
283
|
+
if (usePlacementPreview.getState().node?.id === draftId) {
|
|
284
|
+
usePlacementPreview.getState().clear()
|
|
285
|
+
}
|
|
230
286
|
return
|
|
231
287
|
}
|
|
232
288
|
|
|
@@ -257,6 +313,9 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
257
313
|
draftRef.current = null
|
|
258
314
|
adoptedRef.current = false
|
|
259
315
|
originalStateRef.current = null
|
|
316
|
+
if (usePlacementPreview.getState().node?.id === draftId) {
|
|
317
|
+
usePlacementPreview.getState().clear()
|
|
318
|
+
}
|
|
260
319
|
}, [])
|
|
261
320
|
|
|
262
321
|
return useMemo(
|
|
@@ -62,6 +62,10 @@ import {
|
|
|
62
62
|
type PreviewBounds,
|
|
63
63
|
updateLineGeometry,
|
|
64
64
|
} from '../shared/placement-box-geometry'
|
|
65
|
+
import {
|
|
66
|
+
resolvePointerSupportElevation,
|
|
67
|
+
resolvePointerSupportSurface,
|
|
68
|
+
} from '../shared/pointer-support-cap'
|
|
65
69
|
import {
|
|
66
70
|
getDetachedAttachmentPreviewLift,
|
|
67
71
|
getGridAlignedDimensions,
|
|
@@ -269,6 +273,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
269
273
|
const raycastDisabledMeshRef = useRef<Object3D | null>(null)
|
|
270
274
|
const restoreRaycastsRef = useRef<Array<() => void>>([])
|
|
271
275
|
const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
|
|
276
|
+
// Level-local elevation of the surface the pointer ray actually points
|
|
277
|
+
// at (deck top, floor slab, or ground), refreshed on every grid move.
|
|
278
|
+
// Threaded into the floor-support election as its cap so the POINTER
|
|
279
|
+
// decides the target surface — without it the election lifts the ghost
|
|
280
|
+
// by the MAX overlapping slab and a deck above the aimed-at floor
|
|
281
|
+
// captures the item (and the grid-plane feedback makes it blink).
|
|
282
|
+
const pointerSupportCapRef = useRef<number | null>(null)
|
|
272
283
|
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
|
273
284
|
|
|
274
285
|
// Live camera ref — the shelf-stickiness test reconstructs the cursor world
|
|
@@ -420,6 +431,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
420
431
|
node: previewNode,
|
|
421
432
|
position,
|
|
422
433
|
rotation: previewNode.rotation,
|
|
434
|
+
maxElevation: pointerSupportCapRef.current,
|
|
423
435
|
})
|
|
424
436
|
},
|
|
425
437
|
[asset?.attachTo, draftNode],
|
|
@@ -449,6 +461,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
449
461
|
surfaceItemId: null,
|
|
450
462
|
shelfId: null,
|
|
451
463
|
}
|
|
464
|
+
// No pointer surface known yet — fall back to the uncapped election
|
|
465
|
+
// (an adopted move draft keeps its persisted host until the first move).
|
|
466
|
+
pointerSupportCapRef.current = null
|
|
452
467
|
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
|
453
468
|
gridPosition.current.y = 0
|
|
454
469
|
if (cursorGroupRef.current) {
|
|
@@ -848,6 +863,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
848
863
|
|
|
849
864
|
has3DPointerDrivenMoveRef.current = true
|
|
850
865
|
|
|
866
|
+
// The pointer decides the target surface AND the floor point: cap
|
|
867
|
+
// the floor-support election at the elevation of the surface the
|
|
868
|
+
// camera ray actually hits, and re-aim the event at the ray's
|
|
869
|
+
// crossing of that surface's plane. The grid event's own hit can't
|
|
870
|
+
// be used directly — its plane rides at the ghost's last height, so
|
|
871
|
+
// its Y is a feedback loop (the under-deck blink) and its XZ is
|
|
872
|
+
// perspective-skewed along the ray whenever the plane sits on a
|
|
873
|
+
// different storey than the pointed surface (the skew is what made
|
|
874
|
+
// a drag over a deck-above-a-floor hop between the two surfaces).
|
|
875
|
+
const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
|
|
876
|
+
pointerSupportCapRef.current = pointed?.elevation ?? null
|
|
877
|
+
const surfaceEvent: GridEvent =
|
|
878
|
+
pointed?.worldPoint && pointed.localPoint
|
|
879
|
+
? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint }
|
|
880
|
+
: event
|
|
881
|
+
|
|
851
882
|
// Shelf stickiness: while hosting on a shelf, ignore floor events while
|
|
852
883
|
// the cursor ray still points at the shelf volume (the ray merely slipped
|
|
853
884
|
// off a board / through a gap and hit the floor behind). Detach to the
|
|
@@ -855,10 +886,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
855
886
|
// item oscillates between the shelf row and the floor on every micro-move.
|
|
856
887
|
if (placementState.current.surface === 'shelf-surface') {
|
|
857
888
|
if (cursorRayIntersectsActiveShelf(event.position)) return
|
|
858
|
-
|
|
889
|
+
// Land at the pointed surface's plan point — the raw grid hit is
|
|
890
|
+
// still skewed by the plane riding at the shelf-surface height.
|
|
891
|
+
detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
|
|
859
892
|
}
|
|
860
893
|
|
|
861
|
-
const floorEvent = applyFloorGrabOffset(
|
|
894
|
+
const floorEvent = applyFloorGrabOffset(surfaceEvent)
|
|
862
895
|
|
|
863
896
|
lastRawPos.current.set(
|
|
864
897
|
floorEvent.localPosition[0],
|
|
@@ -941,11 +974,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
941
974
|
|
|
942
975
|
if (draft) draft.position = gridPos
|
|
943
976
|
|
|
944
|
-
// Publish live transform for 2D floorplan
|
|
977
|
+
// Publish live transform for 2D floorplan (and the pointer surface
|
|
978
|
+
// cap, so FloorElevationSystem's per-frame Y agrees with the ghost).
|
|
945
979
|
if (draft) {
|
|
946
980
|
useLiveTransforms.getState().set(draft.id, {
|
|
947
981
|
position: gridPos,
|
|
948
982
|
rotation: cursorGroupRef.current.rotation.y,
|
|
983
|
+
supportElevationCap: pointerSupportCapRef.current ?? undefined,
|
|
949
984
|
})
|
|
950
985
|
}
|
|
951
986
|
|
|
@@ -973,7 +1008,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
973
1008
|
|
|
974
1009
|
const committedId = draftNode.current?.id ?? null
|
|
975
1010
|
const wasAdopted = draftNode.isAdopted
|
|
976
|
-
|
|
1011
|
+
// Carry the pointer surface cap into the commit so the persisted
|
|
1012
|
+
// supportSlabId reproduces the capped election (elects the aimed-at
|
|
1013
|
+
// lower slab — or the ground — instead of a deck hanging above).
|
|
1014
|
+
const finalId = draftNode.commit(result.nodeUpdate, {
|
|
1015
|
+
supportElevationCap: pointerSupportCapRef.current,
|
|
1016
|
+
})
|
|
977
1017
|
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
978
1018
|
draftNode.create(
|
|
979
1019
|
gridPosition.current,
|
|
@@ -1392,6 +1432,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1392
1432
|
|
|
1393
1433
|
const detachItemSurfaceToFloor = (event: ItemEvent) => {
|
|
1394
1434
|
hostSurfaceDragAnchor = null
|
|
1435
|
+
// Landing back on the floor: refresh the pointer surface cap from
|
|
1436
|
+
// this event's world hit so the first floor position already targets
|
|
1437
|
+
// the aimed-at surface (not a deck above it).
|
|
1438
|
+
pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [
|
|
1439
|
+
event.position[0],
|
|
1440
|
+
event.position[1],
|
|
1441
|
+
event.position[2],
|
|
1442
|
+
])
|
|
1395
1443
|
// Coming back from a host: forget the floor grab too, so the item
|
|
1396
1444
|
// centers under the cursor instead of restoring the pre-drag offset —
|
|
1397
1445
|
// and landing on the floor is "anchoring elsewhere", so a later return
|