Adjustments pass: three 0.185, undo/cancel semantics, guide & panel fixes (#496)
* fix(editor): only start handle drags on primary button Right-click over a rotation/move/resize handle started the gesture and stopPropagation()'d, fighting the camera orbit. Guard every gesture starter (shared useHandleDrag, group rotate gizmo, wall endpoint/height/ move, fence move, roof trim) with event.button !== 0 before it swallows the event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(viewer): default units from timezone/locale until user picks Derive the metric/imperial default from the IANA timezone (US, Liberia, Myanmar zones -> imperial; anything else -> metric), falling back to an explicit locale region subtag only when no timezone resolves. Timezone tracks actual location, unlike navigator.language where en-US is a common default far outside the US. The unit is only persisted once the user explicitly sets it, so an untouched preference keeps tracking location; existing persisted values are treated as explicit and left alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(viewer): toggle shadows via renderer.shadowMap.enabled, not castShadow Flipping a light's castShadow at runtime crashes three r184's WebGPU renderer: toggling off disposes the shadow map's GPU texture, but the node builder cache evicts with the post-toggle key, so the shadows-on entry survives still referencing the destroyed texture. Re-enabling reuses that stale state and every frame submit fails with GPUValidationError ("Invalid CommandBuffer from CommandEncoder"). Keep castShadow static and drive the user-facing toggle through the Canvas shadows prop (renderer.shadowMap.enabled), which rebuilds materials without disposing shadow resources. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core,editor): floor undo history at scene load Undo could step back past the scene load into the pre-load (empty) state, wiping the whole project — which autosave would then persist. Two defects: clearSceneHistory() had zero call sites, so every load left the empty pre-load state in zundo's pastStates; and setScene wrote the store twice, recording a half-normalized intermediate as a second undo target. - applySceneGraphToEditor, JSON import, and reset-to-default now clear history so the loaded scene is the undo floor - setScene collapses to a single tracked write (final state identical) - clearSceneHistory also resumes tracking so a load landing inside a pause window can't strand undo recording off Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * build(three): upgrade runtime to 0.185.1, pin @types/three at 0.184 r185 renames directionToColor/colorToDirection to packNormalToRGB/ unpackRGBToNormal and splits SSGI's packed rgba output into separate AO (getAONode, single channel) and GI (getGINode) textures; wind-node's positionLocal reads become positionGeometry. @types/three stays at 0.184.1: the 0.185 typings send tsgo's inference into unbounded allocation (microsoft/typescript-go#2125 class — it ate ~70GB/90s and OOM-killed the machine). viewer/lib/tsl-compat.ts bridges the two renamed TSL exports with 0.184-typed signatures; drop it and the pin together once tsgo copes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(viewer): gate .ktx2 preset-texture loads on detectSupport KTX2Loader.load throws before detectSupport has run, and materials created while a standalone capture canvas's renderer was still initializing cached themselves permanently texture-less — fabric slots rendered white in item thumbnails. .ktx2 loads now await whenKtx2Ready() (resolved by the first successful ensureKtx2Support), which is exported so hosts with standalone canvases can arm it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(viewer): accept host-supplied country for the unit default applyCountryUnitDefault lets the host app feed an authoritative IP-derived country (e.g. Vercel's x-vercel-ip-country) into the unit default. Stronger signal than the timezone heuristic applied at store creation, still never overrides an explicit user choice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): box select starts over locked guide images A locked guide's hit-rect swallowed pointer-down via stopPropagation, so marquee selection couldn't start on top of it. Locked guides now let the event bubble to the svg root; click-to-select and the unlock affordance still work because a non-drag release fires onClick as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): live rotation readout while rotating a guide image Rotating a guide with the 2D handles gave no angle feedback. Reuse the registry layer's RotationAngleOverlay (wedge + degree chip) for guide rotate drags: sweeps from the grabbed corner's bearing at grab to its current snapped bearing, suppressed under ~0.5deg so a fresh grab doesn't flash a sliver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): lingo free-text input in the guide set-scale dialog The real-length field accepts natural measurement text via @pascal-app/lingo — 5'11", 180cm, 1m80, 12ft — parsed in the dropdown's unit (a bare number still means that unit, a typed unit wins). A faint '= 1.80 m' hint previews non-trivial input, unparseable text gets a clear error, and the odd onBlur force-reset to 0.0001 is gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): cmd+z mid-interaction cancels the gesture instead of undoing Undo pressed while the mouse is mid-action (moving, drawing, dragging a handle) used to history-jump under the live pointer — stale carry, half gestures committing against a rewound scene. Now it reads as 'abort this action', exactly like Escape: - the global undo/redo arms first route through the tool:cancel path (covers build drafts, placement ghosts, move tools) and skip the history jump when anything was in flight (consumed, scope-active, or inputDragging); - pointer drags that only knew pointercancel (generic handle drags, group rotate, wall side/height handles, roof trim) gain the same capture-phase Escape/cmd+z keydown the group-move drags already had — fixing Escape for them too; - the existing capture-phase handlers (3D/2D group move, 2D registry move overlay) additionally accept cmd+z as cancel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): box select arms over any guide image that won't drag Follow-up to cd830279, which only let LOCKED guides bubble pointer-down. An unlocked, unselected guide also swallowed the event for nothing (no translate drag starts), so marquee selection could never start on top of it. Now only the one case that uses the event consumes it — selected + unlocked → translate drag — and everything else bubbles to the svg root. Click-to-select still works: a non-drag release never crosses the box-select threshold, so the trailing click fires the guide's onClick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): cmd+z during draft placement cancels instead of undoing The preset/item placement flow (useDraftNode + placement coordinator) registers no interaction scope and holds no pointer, so the cmd+z cancel guard from c699d74e saw it as idle and history-jumped mid-placement. Paused scene history is the universal tell — the draft cycle (and every adopted-move session) keeps temporal paused for the whole gesture, and an undo against a paused store lands on a stale baseline anyway. Treat !isTracking as in-flight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): cmd+z mid-placement completes the cancel, not just skips undo 4ffda723 stopped the history jump during preset/item placement but left the draft alive: the item tool passes no coordinator onCancel — it is Escape's fall-through (switch to select, unmount the tool) that actually destroys the draft. Extract that fall-through and run it from the cmd+z path too whenever a gesture is live and nothing consumed tool:cancel, so cmd+z now behaves exactly like Escape end to end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): node selection clears a lingering reference selection Selecting a guide clears the node selection (handleGuideSelect), but the reverse was never wired: clicking a wall with a floorplan reference selected left selectedReferenceId set, and the panel manager's reference-first priority kept showing the floorplan panel until it was closed by hand. PanelManager now drops the stale reference the moment a scene selection (nodes or zone) appears. Also: the inspector's expanded state is shared across panel swaps by design, but it survived close/reopen too — deselecting everything now resets it, so a fresh selection opens the panel collapsed again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugin-trees): wind displacement reads positionLocal, not positionGeometry The three-0.185 migration renamed positionLocal to positionGeometry in the wind nodes, but positionLocal was never removed in r185 — and the two are not interchangeable here. NodeMaterial.setupPosition applies the instance transform by mutating positionLocal, then overwrites it with positionNode's output; reading raw positionGeometry therefore discarded every instance matrix — leaf cards rendered unscaled at tree-local coordinates (a giant canopy filling the sky) and grass/flower instances collapsed invisibly. Reading positionLocal (instance transform included) restores r184 behavior exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3625d17bfc
commit
a590747748
@@ -32,7 +32,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"three": "^0.184.0",
|
"three": "^0.185.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"three": "^0.184.0",
|
"three": "^0.185.0",
|
||||||
"web-ifc": "^0.0.77",
|
"web-ifc": "^0.0.77",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"three": "^0.184.0",
|
"three": "^0.185.0",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"three": "^0.184.0",
|
"three": "^0.185.0",
|
||||||
"web-ifc": "^0.0.77",
|
"web-ifc": "^0.0.77",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
},
|
},
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"three": "^0.184",
|
"three": "^0.185",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/editor": {
|
"packages/editor": {
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
"next": ">=15",
|
"next": ">=15",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"react-dom": "^18 || ^19",
|
"react-dom": "^18 || ^19",
|
||||||
"three": "^0.184",
|
"three": "^0.185",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/eslint-config": {
|
"packages/eslint-config": {
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"lucide-react": "^1",
|
"lucide-react": "^1",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"three": "^0.184",
|
"three": "^0.185",
|
||||||
"zustand": "^5",
|
"zustand": "^5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/three": "^0.184.0",
|
"@types/three": "^0.184.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"three": "^0.184",
|
"three": "^0.185",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"zod": "^4",
|
"zod": "^4",
|
||||||
"zustand": "^5",
|
"zustand": "^5",
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
"@pascal-app/viewer": "*",
|
"@pascal-app/viewer": "*",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"three": "^0.184",
|
"three": "^0.185",
|
||||||
"zod": "^4",
|
"zod": "^4",
|
||||||
"zustand": "^5",
|
"zustand": "^5",
|
||||||
},
|
},
|
||||||
@@ -333,7 +333,7 @@
|
|||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"three": "^0.184",
|
"three": "^0.185",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"tooling/typescript": {
|
"tooling/typescript": {
|
||||||
@@ -345,7 +345,7 @@
|
|||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/three": "0.184.1",
|
"@types/three": "0.184.1",
|
||||||
"three": "0.184.0",
|
"three": "0.185.1",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
@@ -1816,7 +1816,7 @@
|
|||||||
|
|
||||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||||
|
|
||||||
"three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="],
|
"three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="],
|
||||||
|
|
||||||
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
|
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@
|
|||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/three": "0.184.1",
|
"@types/three": "0.184.1",
|
||||||
"three": "0.184.0"
|
"three": "0.185.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
|
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"three": "^0.184"
|
"three": "^0.185"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
|
|||||||
@@ -1066,14 +1066,6 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set({
|
|
||||||
nodes: cleanedNodes,
|
|
||||||
rootNodeIds,
|
|
||||||
dirtyNodes: new Set<AnyNodeId>(),
|
|
||||||
collections: extra?.collections ?? {},
|
|
||||||
materials,
|
|
||||||
})
|
|
||||||
|
|
||||||
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
|
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
|
||||||
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
|
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
|
||||||
if (normalizedRootNodeIds.length > 0) {
|
if (normalizedRootNodeIds.length > 0) {
|
||||||
@@ -1084,6 +1076,10 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single tracked `set`: with zundo, every tracked write pushes the
|
||||||
|
// pre-write state onto `pastStates`. Writing the scene in two steps
|
||||||
|
// (as this used to) exposed a half-normalized intermediate state —
|
||||||
|
// and the pre-load (possibly empty) state — as undo targets.
|
||||||
set({
|
set({
|
||||||
nodes: cleanedNodes,
|
nodes: cleanedNodes,
|
||||||
rootNodeIds: normalizedRootNodeIds,
|
rootNodeIds: normalizedRootNodeIds,
|
||||||
@@ -1296,6 +1292,12 @@ let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
|||||||
|
|
||||||
export function clearSceneHistory() {
|
export function clearSceneHistory() {
|
||||||
resetSceneHistoryPauseDepth()
|
resetSceneHistoryPauseDepth()
|
||||||
|
// Resetting the pause-depth counter without resuming would strand the
|
||||||
|
// temporal store in `isTracking: false` if a pause window was active when
|
||||||
|
// the scene was (re)loaded — every edit after the load would then be
|
||||||
|
// invisible to undo. Resume unconditionally so the cleared history starts
|
||||||
|
// tracking from the loaded baseline.
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
useScene.temporal.getState().clear()
|
useScene.temporal.getState().clear()
|
||||||
prevPastLength = 0
|
prevPastLength = 0
|
||||||
prevFutureLength = 0
|
prevFutureLength = 0
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"next": ">=15",
|
"next": ">=15",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"react-dom": "^18 || ^19",
|
"react-dom": "^18 || ^19",
|
||||||
"three": "^0.184"
|
"three": "^0.185"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { create } from 'zustand'
|
|||||||
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
|
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||||
import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
|
import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
|
||||||
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
||||||
|
import { isHistoryShortcut } from '../../lib/history'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
import useEditor, {
|
import useEditor, {
|
||||||
@@ -363,7 +364,8 @@ export function startFloorplanGroupMove(
|
|||||||
cancel()
|
cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key !== 'Escape') return
|
// ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer.
|
||||||
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
swallowNextClick()
|
swallowNextClick()
|
||||||
@@ -536,7 +538,8 @@ export function startFloorplanGroupRotate(event: {
|
|||||||
cancel()
|
cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key !== 'Escape') return
|
// ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer.
|
||||||
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
swallowNextClick()
|
swallowNextClick()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
|
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
|
||||||
|
import { isHistoryShortcut } from '../../lib/history'
|
||||||
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
|
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
|
||||||
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
|
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
|
||||||
import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
|
import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
|
||||||
@@ -358,7 +359,13 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key !== 'Escape') return
|
if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
|
||||||
|
if (isHistoryShortcut(event)) {
|
||||||
|
// ⌘Z mid-move cancels like Escape — keep it from reaching the
|
||||||
|
// global undo arm (this handler is capture-phase, that one bubbles).
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
}
|
||||||
// Claim teardown ownership so the 3D move tool's cleanup skips
|
// Claim teardown ownership so the 3D move tool's cleanup skips
|
||||||
// its own restore — without this, both sides would race to
|
// its own restore — without this, both sides would race to
|
||||||
// write the same baseline, harmless but wasteful.
|
// write the same baseline, harmless but wasteful.
|
||||||
@@ -701,7 +708,13 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
|
||||||
|
if (isHistoryShortcut(event)) {
|
||||||
|
// ⌘Z mid-move cancels like Escape — keep it from reaching the
|
||||||
|
// global undo arm (this handler is capture-phase, that one bubbles).
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
}
|
||||||
setMovingNodeOrigin('2d')
|
setMovingNodeOrigin('2d')
|
||||||
if (isFreshPlacement) {
|
if (isFreshPlacement) {
|
||||||
emitter.emit('tool:cancel')
|
emitter.emit('tool:cancel')
|
||||||
@@ -717,15 +730,14 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
setMovingNode(null)
|
setMovingNode(null)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('pointermove', onMove)
|
window.addEventListener('pointermove', onMove)
|
||||||
window.addEventListener('pointerup', onPointerUp)
|
window.addEventListener('pointerup', onPointerUp)
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey, true)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('pointermove', onMove)
|
window.removeEventListener('pointermove', onMove)
|
||||||
window.removeEventListener('pointerup', onPointerUp)
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
window.removeEventListener('keydown', onKey)
|
window.removeEventListener('keydown', onKey, true)
|
||||||
for (const relatedEntry of relatedEntries) {
|
for (const relatedEntry of relatedEntries) {
|
||||||
relatedEntry.removeAttribute('transform')
|
relatedEntry.removeAttribute('transform')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2999,14 +2999,17 @@ const ROTATION_WEDGE_SEGMENTS = 48
|
|||||||
* is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
|
* is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
|
||||||
* horizontally regardless of the building's on-screen orientation.
|
* horizontally regardless of the building's on-screen orientation.
|
||||||
*/
|
*/
|
||||||
function RotationAngleOverlay({
|
export function RotationAngleOverlay({
|
||||||
overlay,
|
overlay,
|
||||||
palette,
|
palette,
|
||||||
unitsPerPixel,
|
unitsPerPixel,
|
||||||
sceneRotationDeg,
|
sceneRotationDeg,
|
||||||
}: {
|
}: {
|
||||||
overlay: RotationOverlayState
|
overlay: RotationOverlayState
|
||||||
palette: FloorplanPalette
|
palette: Pick<
|
||||||
|
FloorplanPalette,
|
||||||
|
'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
|
||||||
|
>
|
||||||
unitsPerPixel: number
|
unitsPerPixel: number
|
||||||
sceneRotationDeg: number
|
sceneRotationDeg: number
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import {
|
|||||||
worldToFloorplanLocalPoint,
|
worldToFloorplanLocalPoint,
|
||||||
} from '../../lib/floorplan'
|
} from '../../lib/floorplan'
|
||||||
import { guideEmitter } from '../../lib/guide-events'
|
import { guideEmitter } from '../../lib/guide-events'
|
||||||
|
import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
|
||||||
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
|
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||||
@@ -126,7 +127,10 @@ import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-laye
|
|||||||
import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
|
import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
|
||||||
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
|
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
|
||||||
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
|
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
|
||||||
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
|
import {
|
||||||
|
FloorplanRegistryLayer,
|
||||||
|
RotationAngleOverlay,
|
||||||
|
} from '../editor-2d/renderers/floorplan-registry-layer'
|
||||||
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
||||||
import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
|
import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
|
||||||
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
||||||
@@ -1412,6 +1416,44 @@ function buildGuideRotationDraft(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Live rotation readout for a guide rotate drag — feeds the registry
|
||||||
|
* layer's wedge + degree chip so guides read the same as every other
|
||||||
|
* rotate affordance. Sweeps from the grabbed corner's bearing at grab to
|
||||||
|
* its current (snapped) bearing; suppressed below ~0.5° so a fresh grab
|
||||||
|
* doesn't flash a zero-width sliver. */
|
||||||
|
function buildGuideRotationReadout(
|
||||||
|
interaction: GuideInteractionState | null,
|
||||||
|
draft: GuideTransformDraft | null,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
interaction &&
|
||||||
|
draft &&
|
||||||
|
interaction.mode === 'rotate' &&
|
||||||
|
draft.guideId === interaction.guideId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = normalizeAngle(getGuideSvgRotation(draft.rotation) - interaction.rotationSvg)
|
||||||
|
if (Math.abs(delta) < 0.0087) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = getGuideWidth(interaction.scale)
|
||||||
|
const height = getGuideHeight(width, interaction.aspectRatio)
|
||||||
|
const startAngle = interaction.rotationSvg + interaction.cornerBaseAngle
|
||||||
|
|
||||||
|
return {
|
||||||
|
pivot: [interaction.centerSvg.x, interaction.centerSvg.y] as const,
|
||||||
|
startAngle,
|
||||||
|
endAngle: startAngle + delta,
|
||||||
|
radius: Math.hypot(width, height) / 2,
|
||||||
|
sweep: Math.abs(delta),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toSvgSelectionBounds(bounds: FloorplanSelectionBounds) {
|
function toSvgSelectionBounds(bounds: FloorplanSelectionBounds) {
|
||||||
return {
|
return {
|
||||||
x: toSvgX(bounds.maxX),
|
x: toSvgX(bounds.maxX),
|
||||||
@@ -2695,6 +2737,39 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REFERENCE_SCALE_LINGO_UNIT: Record<ReferenceScaleUnit, 'm' | 'cm' | 'ft' | 'in'> = {
|
||||||
|
meters: 'm',
|
||||||
|
centimeters: 'cm',
|
||||||
|
feet: 'ft',
|
||||||
|
inches: 'in',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lingo-parse the free-text real-length input in the dropdown's unit — a
|
||||||
|
* bare number means the dropdown unit, while `180cm`, `1m80` or `5'11"`
|
||||||
|
* override it. Returns `null` when the text isn't a readable length. */
|
||||||
|
function parseReferenceScaleLength(raw: string, unit: ReferenceScaleUnit): number | null {
|
||||||
|
const unitId = REFERENCE_SCALE_LINGO_UNIT[unit]
|
||||||
|
return parseMeasurement(
|
||||||
|
raw,
|
||||||
|
{ kind: 'length', unitId },
|
||||||
|
{ bareUnit: unitId, system: unit === 'feet' || unit === 'inches' ? 'us' : 'metric' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceScaleLengthHint(raw: string, unit: ReferenceScaleUnit): string | null {
|
||||||
|
const unitId = REFERENCE_SCALE_LINGO_UNIT[unit]
|
||||||
|
return measurementHint(
|
||||||
|
raw,
|
||||||
|
{ kind: 'length', unitId },
|
||||||
|
{
|
||||||
|
bareUnit: unitId,
|
||||||
|
system: unit === 'feet' || unit === 'inches' ? 'us' : 'metric',
|
||||||
|
displayUnit: unitId,
|
||||||
|
precision: 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) {
|
function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) {
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case 'centimeters':
|
case 'centimeters':
|
||||||
@@ -3149,10 +3224,17 @@ function FloorplanGuideImage({
|
|||||||
}}
|
}}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
if (event.button === 0) {
|
if (event.button === 0) {
|
||||||
event.stopPropagation()
|
// Only a selected, unlocked guide consumes the pointer-down (it
|
||||||
if (isSelected && !isLocked) {
|
// starts a translate drag). Every other guide lets it bubble to
|
||||||
onGuideTranslateStart(guide, event)
|
// the <svg> root so box select arms exactly as on empty canvas.
|
||||||
|
// A non-drag release still fires onClick (a committed box-select
|
||||||
|
// drag swallows the trailing click), so click-to-select → panel
|
||||||
|
// keeps working for locked and unselected guides alike.
|
||||||
|
if (isLocked || !isSelected) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
event.stopPropagation()
|
||||||
|
onGuideTranslateStart(guide, event)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
pointerEvents="all"
|
pointerEvents="all"
|
||||||
@@ -5617,6 +5699,10 @@ export function FloorplanPanel({
|
|||||||
const activeGuideInteractionMode = guideTransformDraft
|
const activeGuideInteractionMode = guideTransformDraft
|
||||||
? (guideInteractionRef.current?.mode ?? null)
|
? (guideInteractionRef.current?.mode ?? null)
|
||||||
: null
|
: null
|
||||||
|
const guideRotationReadout = buildGuideRotationReadout(
|
||||||
|
guideInteractionRef.current,
|
||||||
|
guideTransformDraft,
|
||||||
|
)
|
||||||
const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls])
|
const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls])
|
||||||
const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls])
|
const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls])
|
||||||
const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls])
|
const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls])
|
||||||
@@ -7239,8 +7325,8 @@ export function FloorplanPanel({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayLength = Number(referenceScaleValue)
|
const displayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit)
|
||||||
if (!(displayLength > 0)) {
|
if (!(displayLength && displayLength > 0)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10873,7 +10959,8 @@ export function FloorplanPanel({
|
|||||||
const floorplanNavigationCursor =
|
const floorplanNavigationCursor =
|
||||||
isPanning || isRotatingFloorplan ? 'grabbing' : isSpacePanPressed ? 'grab' : null
|
isPanning || isRotatingFloorplan ? 'grabbing' : isSpacePanPressed ? 'grab' : null
|
||||||
const isFloorplanNavigationOverlayVisible = isSpacePanPressed || isPanning || isRotatingFloorplan
|
const isFloorplanNavigationOverlayVisible = isSpacePanPressed || isPanning || isRotatingFloorplan
|
||||||
const pendingReferenceDisplayLength = Number(referenceScaleValue)
|
const pendingReferenceDisplayLength =
|
||||||
|
parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit) ?? Number.NaN
|
||||||
const pendingReferenceRealLengthMeters =
|
const pendingReferenceRealLengthMeters =
|
||||||
pendingReferenceScale && pendingReferenceDisplayLength > 0
|
pendingReferenceScale && pendingReferenceDisplayLength > 0
|
||||||
? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit)
|
? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit)
|
||||||
@@ -10889,9 +10976,14 @@ export function FloorplanPanel({
|
|||||||
const referenceScaleInputError =
|
const referenceScaleInputError =
|
||||||
referenceScaleValue.trim() === ''
|
referenceScaleValue.trim() === ''
|
||||||
? 'Enter the real length of the line.'
|
? 'Enter the real length of the line.'
|
||||||
|
: Number.isNaN(pendingReferenceDisplayLength)
|
||||||
|
? `Enter a length like 3.5, 180cm or 5'11".`
|
||||||
: pendingReferenceDisplayLength > 0
|
: pendingReferenceDisplayLength > 0
|
||||||
? null
|
? null
|
||||||
: 'Length must be greater than 0.'
|
: 'Length must be greater than 0.'
|
||||||
|
const referenceScaleHint = referenceScaleInputError
|
||||||
|
? null
|
||||||
|
: referenceScaleLengthHint(referenceScaleValue, referenceScaleUnit)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95"
|
className="pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95"
|
||||||
@@ -11005,16 +11097,9 @@ export function FloorplanPanel({
|
|||||||
'h-9 rounded-lg border bg-background px-3 text-sm outline-none transition focus:border-foreground/40',
|
'h-9 rounded-lg border bg-background px-3 text-sm outline-none transition focus:border-foreground/40',
|
||||||
referenceScaleInputError ? 'border-destructive/60' : 'border-border',
|
referenceScaleInputError ? 'border-destructive/60' : 'border-border',
|
||||||
)}
|
)}
|
||||||
inputMode="decimal"
|
|
||||||
onBlur={() => {
|
|
||||||
const value = Number(referenceScaleValue)
|
|
||||||
if (!(value > 0)) {
|
|
||||||
setReferenceScaleValue('0.0001')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(event) => setReferenceScaleValue(event.target.value)}
|
onChange={(event) => setReferenceScaleValue(event.target.value)}
|
||||||
step="any"
|
placeholder={`e.g. 3.5, 180cm or 5'11"`}
|
||||||
type="number"
|
type="text"
|
||||||
value={referenceScaleValue}
|
value={referenceScaleValue}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
@@ -11037,6 +11122,7 @@ export function FloorplanPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{referenceScaleInputError ??
|
{referenceScaleInputError ??
|
||||||
|
referenceScaleHint ??
|
||||||
'Any decimal works. Use the known real length, not the drawn value.'}
|
'Any decimal works. Use the known real length, not the drawn value.'}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -11386,6 +11472,19 @@ export function FloorplanPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{guideRotationReadout && (
|
||||||
|
<RotationAngleOverlay
|
||||||
|
overlay={guideRotationReadout}
|
||||||
|
palette={{
|
||||||
|
measurementLabelBackground: isDark ? '#0f172a' : '#ffffff',
|
||||||
|
measurementLabelText: isDark ? '#e2e8f0' : '#171717',
|
||||||
|
measurementStroke: palette.measurementStroke,
|
||||||
|
}}
|
||||||
|
sceneRotationDeg={floorplanSceneRotationDeg}
|
||||||
|
unitsPerPixel={floorplanUnitsPerPixel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FloorplanDraftCursorLayer
|
<FloorplanDraftCursorLayer
|
||||||
activePolygonDraftPoints={activePolygonDraftPoints}
|
activePolygonDraftPoints={activePolygonDraftPoints}
|
||||||
cursorColor={floorplanCursorColor}
|
cursorColor={floorplanCursorColor}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { type Camera, Plane, type Raycaster, Vector2, Vector3 } from 'three'
|
import { type Camera, Plane, type Raycaster, Vector2, Vector3 } from 'three'
|
||||||
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||||
|
import { isHistoryShortcut } from '../../lib/history'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
import useEditor, {
|
import useEditor, {
|
||||||
@@ -380,7 +381,8 @@ export function armGroupMove3d(args: {
|
|||||||
cancel()
|
cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key !== 'Escape') return
|
// ⌘Z mid-move cancels like Escape — never a history jump under a live pointer.
|
||||||
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
swallowNextClick()
|
swallowNextClick()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
||||||
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
|
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||||
|
import { isHistoryShortcut } from '../../lib/history'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import useInteractionScope, {
|
import useInteractionScope, {
|
||||||
@@ -159,6 +160,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activate = (event: ThreeEvent<PointerEvent>) => {
|
const activate = (event: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
suppressBoxSelectForPointer(event)
|
suppressBoxSelectForPointer(event)
|
||||||
|
|
||||||
@@ -300,6 +302,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|||||||
window.removeEventListener('pointermove', onMove)
|
window.removeEventListener('pointermove', onMove)
|
||||||
window.removeEventListener('pointerup', onUp)
|
window.removeEventListener('pointerup', onUp)
|
||||||
window.removeEventListener('pointercancel', onCancel)
|
window.removeEventListener('pointercancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useViewer.getState().setInputDragging(false)
|
useViewer.getState().setInputDragging(false)
|
||||||
@@ -346,6 +349,16 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape / ⌘Z abort the rotate — capture phase so they win over the global
|
||||||
|
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
swallowNextClick()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
dragCleanupRef.current = () => {
|
dragCleanupRef.current = () => {
|
||||||
clearLivePreviews()
|
clearLivePreviews()
|
||||||
cleanup()
|
cleanup()
|
||||||
@@ -356,6 +369,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|||||||
window.addEventListener('pointermove', onMove)
|
window.addEventListener('pointermove', onMove)
|
||||||
window.addEventListener('pointerup', onUp)
|
window.addEventListener('pointerup', onUp)
|
||||||
window.addEventListener('pointercancel', onCancel)
|
window.addEventListener('pointercancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { type ThreeEvent, useThree } from '@react-three/fiber'
|
import { type ThreeEvent, useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three'
|
import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three'
|
||||||
|
import { isHistoryShortcut } from '../../../lib/history'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
||||||
|
|
||||||
@@ -110,6 +111,9 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|||||||
useEffect(() => () => dragCleanupRef.current?.(), [])
|
useEffect(() => () => dragCleanupRef.current?.(), [])
|
||||||
|
|
||||||
return (event: ThreeEvent<PointerEvent>) => {
|
return (event: ThreeEvent<PointerEvent>) => {
|
||||||
|
// Only the primary button starts a handle gesture — right/middle-drag
|
||||||
|
// belongs to the camera, so let it propagate untouched.
|
||||||
|
if (event.button !== 0) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
suppressBoxSelectForPointer(event)
|
suppressBoxSelectForPointer(event)
|
||||||
|
|
||||||
@@ -185,6 +189,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|||||||
window.removeEventListener('pointermove', onMove)
|
window.removeEventListener('pointermove', onMove)
|
||||||
window.removeEventListener('pointerup', onUp)
|
window.removeEventListener('pointerup', onUp)
|
||||||
window.removeEventListener('pointercancel', onCancel)
|
window.removeEventListener('pointercancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
if (document.body.style.cursor === cursor) {
|
if (document.body.style.cursor === cursor) {
|
||||||
document.body.style.cursor = ''
|
document.body.style.cursor = ''
|
||||||
}
|
}
|
||||||
@@ -222,9 +227,20 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape / ⌘Z abort the drag — capture phase so they win over the global
|
||||||
|
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
swallowNextClick()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
dragCleanupRef.current = cleanup
|
dragCleanupRef.current = cleanup
|
||||||
window.addEventListener('pointermove', onMove)
|
window.addEventListener('pointermove', onMove)
|
||||||
window.addEventListener('pointerup', onUp)
|
window.addEventListener('pointerup', onUp)
|
||||||
window.addEventListener('pointercancel', onCancel)
|
window.addEventListener('pointercancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
GRID_LAYER,
|
GRID_LAYER,
|
||||||
getSceneTheme,
|
getSceneTheme,
|
||||||
horizonHazeColor,
|
horizonHazeColor,
|
||||||
|
packNormalToRGB,
|
||||||
SSGI_PARAMS,
|
SSGI_PARAMS,
|
||||||
snapLevelsToTruePositions,
|
snapLevelsToTruePositions,
|
||||||
|
unpackRGBToNormal,
|
||||||
useViewer,
|
useViewer,
|
||||||
} from '@pascal-app/viewer'
|
} from '@pascal-app/viewer'
|
||||||
import type { CameraControls } from '@react-three/drei'
|
import type { CameraControls } from '@react-three/drei'
|
||||||
@@ -20,10 +22,8 @@ import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
|||||||
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
||||||
import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'
|
import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'
|
||||||
import {
|
import {
|
||||||
colorToDirection,
|
|
||||||
convertToTexture,
|
convertToTexture,
|
||||||
diffuseColor,
|
diffuseColor,
|
||||||
directionToColor,
|
|
||||||
float,
|
float,
|
||||||
mix,
|
mix,
|
||||||
mrt,
|
mrt,
|
||||||
@@ -105,7 +105,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
mrt({
|
mrt({
|
||||||
output,
|
output,
|
||||||
diffuseColor,
|
diffuseColor,
|
||||||
normal: directionToColor(normalView),
|
normal: packNormalToRGB(normalView),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
scenePass.getTexture('diffuseColor').type = UnsignedByteType
|
scenePass.getTexture('diffuseColor').type = UnsignedByteType
|
||||||
scenePass.getTexture('normal').type = UnsignedByteType
|
scenePass.getTexture('normal').type = UnsignedByteType
|
||||||
|
|
||||||
const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
|
const sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv)))
|
||||||
|
|
||||||
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any)
|
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any)
|
||||||
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
|
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
|
||||||
@@ -131,8 +131,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
||||||
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
|
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
|
||||||
|
|
||||||
const giTexture = (giPass as any).getTextureNode()
|
// r185: SSGI's AO lives in its own single-channel texture (getAONode)
|
||||||
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
|
// rather than the alpha of one packed rgba texture.
|
||||||
|
const aoTexture = (giPass as any).getAONode()
|
||||||
|
const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1))
|
||||||
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam)
|
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam)
|
||||||
denoisePass.index.value = 0
|
denoisePass.index.value = 0
|
||||||
denoisePass.radius.value = 4
|
denoisePass.radius.value = 4
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from 'three'
|
} from 'three'
|
||||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { isHistoryShortcut } from '../../lib/history'
|
||||||
import { endpointReshapeScope } from '../../lib/interaction/scope'
|
import { endpointReshapeScope } from '../../lib/interaction/scope'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
@@ -331,6 +332,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
|
const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
suppressBoxSelectForPointer(event)
|
suppressBoxSelectForPointer(event)
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
@@ -435,6 +437,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
|
const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
|
||||||
|
|
||||||
const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
|
const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
suppressBoxSelectForPointer(event)
|
suppressBoxSelectForPointer(event)
|
||||||
const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
|
const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
|
||||||
@@ -496,6 +499,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
window.removeEventListener('pointermove', onMove)
|
window.removeEventListener('pointermove', onMove)
|
||||||
window.removeEventListener('pointerup', onUp)
|
window.removeEventListener('pointerup', onUp)
|
||||||
window.removeEventListener('pointercancel', onCancel)
|
window.removeEventListener('pointercancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
if (document.body.style.cursor === 'ns-resize') {
|
if (document.body.style.cursor === 'ns-resize') {
|
||||||
document.body.style.cursor = ''
|
document.body.style.cursor = ''
|
||||||
}
|
}
|
||||||
@@ -525,10 +529,21 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape / ⌘Z abort the drag — capture phase so they win over the global
|
||||||
|
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
swallowNextClick()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
dragCleanupRef.current = cleanup
|
dragCleanupRef.current = cleanup
|
||||||
window.addEventListener('pointermove', onMove)
|
window.addEventListener('pointermove', onMove)
|
||||||
window.addEventListener('pointerup', onUp)
|
window.addEventListener('pointerup', onUp)
|
||||||
window.addEventListener('pointercancel', onCancel)
|
window.addEventListener('pointercancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -607,6 +622,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
|||||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||||
|
|
||||||
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
|
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
suppressBoxSelectForPointer(event)
|
suppressBoxSelectForPointer(event)
|
||||||
document.body.style.cursor = 'grabbing'
|
document.body.style.cursor = 'grabbing'
|
||||||
@@ -696,6 +712,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
|||||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||||
|
|
||||||
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
|
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
suppressBoxSelectForPointer(event)
|
suppressBoxSelectForPointer(event)
|
||||||
document.body.style.cursor = 'grabbing'
|
document.body.style.cursor = 'grabbing'
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import * as THREE from 'three'
|
|||||||
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
|
import { isHistoryShortcut } from '../../../lib/history'
|
||||||
import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy'
|
import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy'
|
||||||
import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope'
|
import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope'
|
||||||
import { swallowNextClick } from '../../editor/handles/use-handle-drag'
|
import { swallowNextClick } from '../../editor/handles/use-handle-drag'
|
||||||
@@ -1250,6 +1251,7 @@ function RoofTrimHandles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startDrag = (side: RoofTrimSide, event: ThreeEvent<PointerEvent>) => {
|
const startDrag = (side: RoofTrimSide, event: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const source = sceneRegistry.nodes.get(segment.id)
|
const source = sceneRegistry.nodes.get(segment.id)
|
||||||
if (!source) return
|
if (!source) return
|
||||||
@@ -1340,6 +1342,7 @@ function RoofTrimHandles() {
|
|||||||
window.removeEventListener('pointermove', onMove)
|
window.removeEventListener('pointermove', onMove)
|
||||||
window.removeEventListener('pointerup', onUp)
|
window.removeEventListener('pointerup', onUp)
|
||||||
window.removeEventListener('pointercancel', onCancel)
|
window.removeEventListener('pointercancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
if (
|
if (
|
||||||
document.body.style.cursor === 'ew-resize' ||
|
document.body.style.cursor === 'ew-resize' ||
|
||||||
document.body.style.cursor === 'ns-resize' ||
|
document.body.style.cursor === 'ns-resize' ||
|
||||||
@@ -1378,10 +1381,21 @@ function RoofTrimHandles() {
|
|||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape / ⌘Z abort the trim drag — capture phase so they win over the
|
||||||
|
// global use-keyboard arms (⌘Z must never history-jump mid-gesture).
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
swallowNextClick()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
dragCleanupRef.current = cleanup
|
dragCleanupRef.current = cleanup
|
||||||
window.addEventListener('pointermove', onMove)
|
window.addEventListener('pointermove', onMove)
|
||||||
window.addEventListener('pointerup', onUp)
|
window.addEventListener('pointerup', onUp)
|
||||||
window.addEventListener('pointercancel', onCancel)
|
window.addEventListener('pointercancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderTrimPlane = (
|
const renderTrimPlane = (
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import useEditor from '../../../store/use-editor'
|
|||||||
import { MobilePanelSheet } from './mobile-panel-sheet'
|
import { MobilePanelSheet } from './mobile-panel-sheet'
|
||||||
import { MobileSelectionBar } from './mobile-selection-bar'
|
import { MobileSelectionBar } from './mobile-selection-bar'
|
||||||
import { getNodeDisplay } from './node-display'
|
import { getNodeDisplay } from './node-display'
|
||||||
|
import { resetDesktopInspectorCollapsed } from './panel-wrapper'
|
||||||
import { ParametricInspector } from './parametric-inspector'
|
import { ParametricInspector } from './parametric-inspector'
|
||||||
import { ReferencePanel } from './reference-panel'
|
import { ReferencePanel } from './reference-panel'
|
||||||
|
|
||||||
@@ -186,6 +187,27 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
|
|||||||
return id ? (s.nodes[id as AnyNodeId] ?? null) : null
|
return id ? (s.nodes[id as AnyNodeId] ?? null) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Node and reference selection are mutually exclusive: selecting a guide
|
||||||
|
// clears the node selection (handleGuideSelect), but node selection never
|
||||||
|
// cleared a lingering reference — so clicking a wall with a floorplan
|
||||||
|
// selected kept showing the reference panel. Clear the stale reference the
|
||||||
|
// moment a scene selection appears.
|
||||||
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIds.length > 0 || selectedZoneId) {
|
||||||
|
setSelectedReferenceId(null)
|
||||||
|
}
|
||||||
|
}, [selectedIds, selectedZoneId, setSelectedReferenceId])
|
||||||
|
|
||||||
|
// The inspector's expanded state is shared across panel swaps, but a fresh
|
||||||
|
// selection after everything was deselected should open collapsed again.
|
||||||
|
const hasAnySelection = selectedIds.length > 0 || Boolean(selectedZoneId) || Boolean(selectedReferenceId)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasAnySelection) {
|
||||||
|
resetDesktopInspectorCollapsed()
|
||||||
|
}
|
||||||
|
}, [hasAnySelection])
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
if (selectedReferenceId) {
|
if (selectedReferenceId) {
|
||||||
return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
|
return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ const DRAG_MARGIN = 8
|
|||||||
const CLICK_SLOP = 4
|
const CLICK_SLOP = 4
|
||||||
let desktopInspectorCollapsed = true
|
let desktopInspectorCollapsed = true
|
||||||
|
|
||||||
|
/** Forget the shared expanded state. Called when the last selection clears so
|
||||||
|
* a fresh selection opens the inspector collapsed — the sharing is only meant
|
||||||
|
* to survive swaps between panels (roof ↔ segment), not a close/reopen. */
|
||||||
|
export function resetDesktopInspectorCollapsed() {
|
||||||
|
desktopInspectorCollapsed = true
|
||||||
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(Math.max(value, min), Math.max(min, max))
|
return Math.min(Math.max(value, min), Math.max(min, max))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { emitter, useScene, validateBuildJson } from '@pascal-app/core'
|
import { clearSceneHistory, emitter, useScene, validateBuildJson } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { TreeView, VisualJson } from '@visual-json/react'
|
import { TreeView, VisualJson } from '@visual-json/react'
|
||||||
import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
|
import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
|
||||||
@@ -267,6 +267,9 @@ export function SettingsPanel({
|
|||||||
parsed.nodes as Parameters<typeof setScene>[0],
|
parsed.nodes as Parameters<typeof setScene>[0],
|
||||||
parsed.rootNodeIds as Parameters<typeof setScene>[1],
|
parsed.rootNodeIds as Parameters<typeof setScene>[1],
|
||||||
)
|
)
|
||||||
|
// An import is a scene load: it becomes the undo floor. Without this,
|
||||||
|
// undo could step back into the pre-import scene state.
|
||||||
|
clearSceneHistory()
|
||||||
resetSelection()
|
resetSelection()
|
||||||
setPhase('site')
|
setPhase('site')
|
||||||
setPendingImport(null)
|
setPendingImport(null)
|
||||||
@@ -274,6 +277,9 @@ export function SettingsPanel({
|
|||||||
|
|
||||||
const handleResetToDefault = () => {
|
const handleResetToDefault = () => {
|
||||||
clearScene()
|
clearScene()
|
||||||
|
// Same floor rule as import — undo after a reset must not resurrect the
|
||||||
|
// old scene (or land on the empty intermediate `unloadScene` state).
|
||||||
|
clearSceneHistory()
|
||||||
resetSelection()
|
resetSelection()
|
||||||
setPhase('structure')
|
setPhase('structure')
|
||||||
selectDefaultBuildingAndLevel()
|
selectDefaultBuildingAndLevel()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
|
|||||||
import { toggleDoorOpenState } from '../lib/door-interaction'
|
import { toggleDoorOpenState } from '../lib/door-interaction'
|
||||||
import { guideEmitter } from '../lib/guide-events'
|
import { guideEmitter } from '../lib/guide-events'
|
||||||
import { runRedo, runUndo } from '../lib/history'
|
import { runRedo, runUndo } from '../lib/history'
|
||||||
|
import { isActive } from '../lib/interaction/scope'
|
||||||
import {
|
import {
|
||||||
copySelectedNodesToEditorClipboard,
|
copySelectedNodesToEditorClipboard,
|
||||||
pasteEditorClipboardToLevel,
|
pasteEditorClipboardToLevel,
|
||||||
@@ -98,6 +99,64 @@ export const markToolCancelConsumed = () => {
|
|||||||
_toolCancelConsumed = true
|
_toolCancelConsumed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape's fall-through when no tool consumed the cancel: drop back to the
|
||||||
|
// select tool (keeping building/level context) and close panels. Tools like
|
||||||
|
// preset/item placement rely on this — they pass no coordinator onCancel, and
|
||||||
|
// it is the mode switch unmounting them that destroys the draft.
|
||||||
|
const exitToSelectAfterUnconsumedCancel = () => {
|
||||||
|
const currentPhase = useEditor.getState().phase
|
||||||
|
const currentStructureLayer = useEditor.getState().structureLayer
|
||||||
|
|
||||||
|
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
||||||
|
|
||||||
|
// From zone mode, return to structure select
|
||||||
|
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
|
||||||
|
useEditor.getState().setStructureLayer('elements')
|
||||||
|
useEditor.getState().setMode('select')
|
||||||
|
} else {
|
||||||
|
// Return to the default select tool while keeping the active building/level context.
|
||||||
|
useEditor.getState().setMode('select')
|
||||||
|
}
|
||||||
|
|
||||||
|
useEditor.getState().setFloorplanSelectionTool('click')
|
||||||
|
|
||||||
|
// Clear selections to close UI panels, but KEEP the active building and level context.
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||||
|
useEditor.getState().setSelectedReferenceId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…)
|
||||||
|
// reads as "abort this action", not history undo — behave exactly like Escape
|
||||||
|
// and report whether anything was in flight so the undo/redo arms know to
|
||||||
|
// skip the history jump. Pointer drags that only listen for their own
|
||||||
|
// capture-phase keydown never reach here — they stopPropagation first (see
|
||||||
|
// isHistoryShortcut call sites).
|
||||||
|
const cancelInteractionForHistoryShortcut = () => {
|
||||||
|
if (useEditor.getState().referenceScaleActiveGuideId) {
|
||||||
|
guideEmitter.emit('guide:cancel-reference-scale')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_toolCancelConsumed = false
|
||||||
|
emitter.emit('tool:cancel')
|
||||||
|
if (_toolCancelConsumed) return true
|
||||||
|
if (
|
||||||
|
isActive(useInteractionScope.getState().scope) ||
|
||||||
|
useViewer.getState().inputDragging ||
|
||||||
|
// Paused history means a gesture session is live (draft placement, adopted
|
||||||
|
// move, …) even when no scope/drag flag is set — the preset/item draft
|
||||||
|
// cycle keeps temporal paused for the whole session, and a history jump
|
||||||
|
// against a paused store would land on a stale baseline anyway.
|
||||||
|
!useScene.temporal.getState().isTracking
|
||||||
|
) {
|
||||||
|
// A gesture is live but nothing consumed the cancel: finish it the way
|
||||||
|
// Escape does — the mode switch is what actually cancels tools that hook
|
||||||
|
// their teardown to unmount (preset/item placement).
|
||||||
|
exitToSelectAfterUnconsumedCancel()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export const useKeyboard = ({
|
export const useKeyboard = ({
|
||||||
isVersionPreviewMode = false,
|
isVersionPreviewMode = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -233,27 +292,7 @@ export const useKeyboard = ({
|
|||||||
// Only switch to select mode if no tool had an active mid-action to cancel.
|
// Only switch to select mode if no tool had an active mid-action to cancel.
|
||||||
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
|
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
|
||||||
if (!_toolCancelConsumed) {
|
if (!_toolCancelConsumed) {
|
||||||
const currentPhase = useEditor.getState().phase
|
exitToSelectAfterUnconsumedCancel()
|
||||||
const currentStructureLayer = useEditor.getState().structureLayer
|
|
||||||
|
|
||||||
useInteractionScope
|
|
||||||
.getState()
|
|
||||||
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
|
||||||
|
|
||||||
// From zone mode, return to structure select
|
|
||||||
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
|
|
||||||
useEditor.getState().setStructureLayer('elements')
|
|
||||||
useEditor.getState().setMode('select')
|
|
||||||
} else {
|
|
||||||
// Return to the default select tool while keeping the active building/level context.
|
|
||||||
useEditor.getState().setMode('select')
|
|
||||||
}
|
|
||||||
|
|
||||||
useEditor.getState().setFloorplanSelectionTool('click')
|
|
||||||
|
|
||||||
// Clear selections to close UI panels, but KEEP the active building and level context.
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
|
||||||
useEditor.getState().setSelectedReferenceId(null)
|
|
||||||
}
|
}
|
||||||
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
|
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -323,10 +362,12 @@ export const useKeyboard = ({
|
|||||||
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||||
if (isVersionPreviewMode) return
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (cancelInteractionForHistoryShortcut()) return
|
||||||
runUndo()
|
runUndo()
|
||||||
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||||
if (isVersionPreviewMode) return
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (cancelInteractionForHistoryShortcut()) return
|
||||||
runRedo()
|
runRedo()
|
||||||
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -19,3 +19,12 @@ export function runRedo() {
|
|||||||
useScene.temporal.getState().redo()
|
useScene.temporal.getState().redo()
|
||||||
refreshSceneAfterHistoryJump()
|
refreshSceneAfterHistoryJump()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⌘Z / ⌘⇧Z (undo/redo). Pointer-drag sessions intercept these in the capture
|
||||||
|
* phase and cancel the gesture instead — mid-drag, "undo" means "abort what my
|
||||||
|
* mouse is doing", never a history jump under a live pointer.
|
||||||
|
*/
|
||||||
|
export function isHistoryShortcut(e: KeyboardEvent) {
|
||||||
|
return (e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { nodeRegistry, resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
clearSceneHistory,
|
||||||
|
nodeRegistry,
|
||||||
|
resolveLevelId,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import useEditor, {
|
import useEditor, {
|
||||||
hasCustomPersistedEditorUiState,
|
hasCustomPersistedEditorUiState,
|
||||||
@@ -385,6 +391,12 @@ export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
|||||||
useScene.getState().clearScene()
|
useScene.getState().clearScene()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The loaded scene is the undo floor. Loading records history entries of
|
||||||
|
// its own (`unloadScene` + `setScene`/`clearScene` are tracked writes), so
|
||||||
|
// without this reset a few Ctrl+Z presses could step past the load into the
|
||||||
|
// pre-load — often empty — state and wipe the whole project.
|
||||||
|
clearSceneHistory()
|
||||||
|
|
||||||
syncEditorSelectionFromCurrentScene()
|
syncEditorSelectionFromCurrentScene()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"lucide-react": "^1",
|
"lucide-react": "^1",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"three": "^0.184",
|
"three": "^0.185",
|
||||||
"zustand": "^5"
|
"zustand": "^5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"@pascal-app/viewer": "*",
|
"@pascal-app/viewer": "*",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"three": "^0.184",
|
"three": "^0.185",
|
||||||
"zod": "^4",
|
"zod": "^4",
|
||||||
"zustand": "^5"
|
"zustand": "^5"
|
||||||
},
|
},
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/three": "^0.184.0",
|
"@types/three": "^0.184.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"three": "^0.184",
|
"three": "^0.185",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"zod": "^4",
|
"zod": "^4",
|
||||||
"zustand": "^5"
|
"zustand": "^5"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"three": "^0.184"
|
"three": "^0.185"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"three-bvh-csg": "^0.0.18",
|
"three-bvh-csg": "^0.0.18",
|
||||||
|
|||||||
@@ -446,6 +446,14 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
|||||||
|
|
||||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||||
const transparentBackground = useViewer((state) => state.transparentBackground)
|
const transparentBackground = useViewer((state) => state.transparentBackground)
|
||||||
|
// The shadows toggle drives `renderer.shadowMap.enabled` (via the Canvas
|
||||||
|
// `shadows` prop) rather than the lights' `castShadow`: toggling castShadow
|
||||||
|
// off disposes the shadow map's GPU texture but three r184's WebGPU node
|
||||||
|
// cache keeps the shadows-on builder state that references it, so toggling
|
||||||
|
// back on reuses destroyed resources and every frame submit fails with a
|
||||||
|
// GPUValidationError. Disabling at the renderer level rebuilds materials
|
||||||
|
// without disposing anything, so the round-trip is safe.
|
||||||
|
const shadowsEnabled = useViewer((state) => state.shadows)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (transparent === undefined) return
|
if (transparent === undefined) return
|
||||||
|
|
||||||
@@ -549,7 +557,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
|||||||
}}
|
}}
|
||||||
shadows={{
|
shadows={{
|
||||||
type: THREE.PCFShadowMap,
|
type: THREE.PCFShadowMap,
|
||||||
enabled: true,
|
enabled: shadowsEnabled,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FrameLimiter fps={50} />
|
<FrameLimiter fps={50} />
|
||||||
|
|||||||
@@ -244,8 +244,16 @@ export function Lights() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{theme.lights.map((light, index) => (
|
{theme.lights.map((light, index) => (
|
||||||
|
// The user-facing shadows toggle must NOT flip `castShadow` at runtime:
|
||||||
|
// three r184's WebGPU node cache keys builder state by castShadow, but
|
||||||
|
// evicts with the post-toggle key, so flipping off disposes the shadow
|
||||||
|
// map's GPU texture while the shadows-on cache entry (still referencing
|
||||||
|
// it) survives. Re-enabling then reuses that stale state and every
|
||||||
|
// submit fails ("Invalid CommandBuffer ... renderContext_N"). The
|
||||||
|
// toggle is applied via `renderer.shadowMap.enabled` (Canvas `shadows`
|
||||||
|
// prop in viewer/index.tsx), which round-trips without disposing.
|
||||||
<directionalLight
|
<directionalLight
|
||||||
castShadow={Boolean(light.castShadow) && !SHADOWS_DISABLED && shadows}
|
castShadow={Boolean(light.castShadow) && !SHADOWS_DISABLED}
|
||||||
key={`${index}-${light.position.join(',')}`}
|
key={`${index}-${light.position.join(',')}`}
|
||||||
position={light.position}
|
position={light.position}
|
||||||
ref={(ref) => {
|
ref={(ref) => {
|
||||||
@@ -256,7 +264,7 @@ export function Lights() {
|
|||||||
shadow-normalBias={0.3}
|
shadow-normalBias={0.3}
|
||||||
shadow-radius={2}
|
shadow-radius={2}
|
||||||
>
|
>
|
||||||
{light.castShadow && !SHADOWS_DISABLED && shadows ? (
|
{light.castShadow && !SHADOWS_DISABLED ? (
|
||||||
<orthographicCamera
|
<orthographicCamera
|
||||||
attach="shadow-camera"
|
attach="shadow-camera"
|
||||||
bottom={-shadowCameraSize}
|
bottom={-shadowCameraSize}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
|||||||
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
colorToDirection,
|
|
||||||
diffuseColor,
|
diffuseColor,
|
||||||
directionToColor,
|
|
||||||
float,
|
float,
|
||||||
mix,
|
mix,
|
||||||
mrt,
|
mrt,
|
||||||
@@ -34,6 +32,7 @@ import { inkedEdges } from '../../lib/ink-edges'
|
|||||||
import { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
|
import { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
|
||||||
import { mergedOutline } from '../../lib/merged-outline-node'
|
import { mergedOutline } from '../../lib/merged-outline-node'
|
||||||
import { getSceneTheme } from '../../lib/scene-themes'
|
import { getSceneTheme } from '../../lib/scene-themes'
|
||||||
|
import { packNormalToRGB, unpackRGBToNormal } from '../../lib/tsl-compat'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
// Scene-referred grade applied before the output tone mapping (AgX). AgX rolls
|
// Scene-referred grade applied before the output tone mapping (AgX). AgX rolls
|
||||||
@@ -423,7 +422,7 @@ const PostProcessingPasses = ({
|
|||||||
mrt({
|
mrt({
|
||||||
output,
|
output,
|
||||||
diffuseColor,
|
diffuseColor,
|
||||||
normal: directionToColor(normalView),
|
normal: packNormalToRGB(normalView),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
scenePassDepth = scenePass.getTextureNode('depth')
|
scenePassDepth = scenePass.getTextureNode('depth')
|
||||||
@@ -431,7 +430,7 @@ const PostProcessingPasses = ({
|
|||||||
const normalTexture = scenePass.getTexture('normal')
|
const normalTexture = scenePass.getTexture('normal')
|
||||||
normalTexture.type = UnsignedByteType
|
normalTexture.type = UnsignedByteType
|
||||||
// Extract normal from color-encoded texture (SSGI consumes the node form)
|
// Extract normal from color-encoded texture (SSGI consumes the node form)
|
||||||
sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
|
sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ssgiEnabled) {
|
if (ssgiEnabled) {
|
||||||
@@ -452,14 +451,16 @@ const PostProcessingPasses = ({
|
|||||||
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
||||||
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
|
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
|
||||||
|
|
||||||
const giTexture = (giPass as any).getTextureNode()
|
// r185: SSGI renders AO and GI into two separate textures (R8 + RG11B10)
|
||||||
|
// exposed via getAONode()/getGINode() instead of one rgba texture.
|
||||||
|
const aoTexture = (giPass as any).getAONode()
|
||||||
|
|
||||||
const gi = giPass.rgb
|
const gi = (giPass as any).getGINode().rgb
|
||||||
let ao: any
|
let ao: any
|
||||||
if (denoiseEnabled) {
|
if (denoiseEnabled) {
|
||||||
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
|
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
|
||||||
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
|
// SSGI's AO is a single red channel, so we remap it into RGB before denoising.
|
||||||
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
|
const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1))
|
||||||
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
|
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
|
||||||
denoisePass.index.value = 0
|
denoisePass.index.value = 0
|
||||||
denoisePass.radius.value = 4
|
denoisePass.radius.value = 4
|
||||||
@@ -467,7 +468,7 @@ const PostProcessingPasses = ({
|
|||||||
} else {
|
} else {
|
||||||
// Diagnostic path: feed raw noisy SSGI AO straight through. Will
|
// Diagnostic path: feed raw noisy SSGI AO straight through. Will
|
||||||
// look grainy — that's the point, it isolates denoise cost.
|
// look grainy — that's the point, it isolates denoise cost.
|
||||||
ao = giTexture.a
|
ao = aoTexture.r
|
||||||
}
|
}
|
||||||
|
|
||||||
// AO is a near/mid-field cue like the ink: fade it out with raw depth
|
// AO is a near/mid-field cue like the ink: fade it out with raw depth
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export {
|
|||||||
collectIsolationSubtree,
|
collectIsolationSubtree,
|
||||||
isIsolationActive,
|
isIsolationActive,
|
||||||
} from './lib/isolation'
|
} from './lib/isolation'
|
||||||
|
export { ensureKtx2Support } from './lib/ktx2-loader'
|
||||||
export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
||||||
export {
|
export {
|
||||||
applyMaterialPresetToMaterials,
|
applyMaterialPresetToMaterials,
|
||||||
@@ -107,8 +108,9 @@ export {
|
|||||||
SCENE_THEMES,
|
SCENE_THEMES,
|
||||||
type SceneTheme,
|
type SceneTheme,
|
||||||
} from './lib/scene-themes'
|
} from './lib/scene-themes'
|
||||||
|
export { packNormalToRGB, unpackRGBToNormal } from './lib/tsl-compat'
|
||||||
export { useItemLightPool } from './store/use-item-light-pool'
|
export { useItemLightPool } from './store/use-item-light-pool'
|
||||||
export { default as useViewer } from './store/use-viewer'
|
export { applyCountryUnitDefault, default as useViewer } from './store/use-viewer'
|
||||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||||
export {
|
export {
|
||||||
createColumnBoxGeometry,
|
createColumnBoxGeometry,
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import {
|
import { abs, float, max, min, mix, screenSize, screenUV, smoothstep, vec2 } from 'three/tsl'
|
||||||
abs,
|
|
||||||
colorToDirection,
|
import { unpackRGBToNormal } from './tsl-compat'
|
||||||
float,
|
|
||||||
max,
|
|
||||||
min,
|
|
||||||
mix,
|
|
||||||
screenSize,
|
|
||||||
screenUV,
|
|
||||||
smoothstep,
|
|
||||||
vec2,
|
|
||||||
} from 'three/tsl'
|
|
||||||
|
|
||||||
// Screen-space ink outline (SketchUp / Moebius look). Reads the scene-pass
|
// Screen-space ink outline (SketchUp / Moebius look). Reads the scene-pass
|
||||||
// depth + normal MRT and inks two signals:
|
// depth + normal MRT and inks two signals:
|
||||||
@@ -62,11 +53,11 @@ export function inkedEdges({
|
|||||||
// ≈ metres of step / near (near≈0.1): ~5cm starts a line, ~25cm solid.
|
// ≈ metres of step / near (near≈0.1): ~5cm starts a line, ~25cm solid.
|
||||||
const depthEdge = smoothstep(float(0.5), float(2.5), depthMetric).mul(noiseGate)
|
const depthEdge = smoothstep(float(0.5), float(2.5), depthMetric).mul(noiseGate)
|
||||||
|
|
||||||
const nC = colorToDirection(normalTex.sample(uvN)).normalize()
|
const nC = unpackRGBToNormal(normalTex.sample(uvN)).normalize()
|
||||||
const nR = colorToDirection(normalTex.sample(uvN.add(vec2(px.x, 0)))).normalize()
|
const nR = unpackRGBToNormal(normalTex.sample(uvN.add(vec2(px.x, 0)))).normalize()
|
||||||
const nL = colorToDirection(normalTex.sample(uvN.sub(vec2(px.x, 0)))).normalize()
|
const nL = unpackRGBToNormal(normalTex.sample(uvN.sub(vec2(px.x, 0)))).normalize()
|
||||||
const nU = colorToDirection(normalTex.sample(uvN.add(vec2(0, px.y)))).normalize()
|
const nU = unpackRGBToNormal(normalTex.sample(uvN.add(vec2(0, px.y)))).normalize()
|
||||||
const nD = colorToDirection(normalTex.sample(uvN.sub(vec2(0, px.y)))).normalize()
|
const nD = unpackRGBToNormal(normalTex.sample(uvN.sub(vec2(0, px.y)))).normalize()
|
||||||
// Ink is a near/mid-field affordance: fade it out with raw depth so the
|
// Ink is a near/mid-field affordance: fade it out with raw depth so the
|
||||||
// horizon (the infinite ground disc vanishing against the backdrop) and
|
// horizon (the infinite ground disc vanishing against the backdrop) and
|
||||||
// other far-field depth cliffs never draw a line across the sky junction.
|
// other far-field depth cliffs never draw a line across the sky junction.
|
||||||
|
|||||||
@@ -122,6 +122,22 @@ ktx2Loader.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@mas
|
|||||||
const configuredRenderers = new WeakSet<object>()
|
const configuredRenderers = new WeakSet<object>()
|
||||||
const warnedRenderers = new WeakSet<object>()
|
const warnedRenderers = new WeakSet<object>()
|
||||||
|
|
||||||
|
let resolveKtx2Ready: () => void
|
||||||
|
const ktx2ReadyPromise = new Promise<void>((resolve) => {
|
||||||
|
resolveKtx2Ready = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves once `detectSupport` has succeeded for any renderer. `.ktx2` loads
|
||||||
|
* issued before that point would throw inside KTX2Loader ("Missing
|
||||||
|
* initialization with `.detectSupport( renderer )`"), so texture loaders await
|
||||||
|
* this instead of failing — covers materials created while the renderer is
|
||||||
|
* still initializing (e.g. a standalone capture canvas).
|
||||||
|
*/
|
||||||
|
export function whenKtx2Ready(): Promise<void> {
|
||||||
|
return ktx2ReadyPromise
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns true once support has been detected for this renderer (KTX2 safe to load). */
|
/** Returns true once support has been detected for this renderer (KTX2 safe to load). */
|
||||||
export function ensureKtx2Support(renderer: unknown): boolean {
|
export function ensureKtx2Support(renderer: unknown): boolean {
|
||||||
const key = renderer as object | null
|
const key = renderer as object | null
|
||||||
@@ -130,6 +146,7 @@ export function ensureKtx2Support(renderer: unknown): boolean {
|
|||||||
try {
|
try {
|
||||||
;(ktx2Loader as unknown as { detectSupport: (r: unknown) => void }).detectSupport(renderer)
|
;(ktx2Loader as unknown as { detectSupport: (r: unknown) => void }).detectSupport(renderer)
|
||||||
configuredRenderers.add(key)
|
configuredRenderers.add(key)
|
||||||
|
resolveKtx2Ready()
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Some WebGPU flows can transiently call this before backend init; don't
|
// Some WebGPU flows can transiently call this before backend init; don't
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { float, mix, positionViewDirection, transformedNormalView } from 'three/
|
|||||||
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
|
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
|
|
||||||
import { resolveCdnUrl } from './asset-url'
|
import { resolveCdnUrl } from './asset-url'
|
||||||
import { isKtx2Url, ktx2Loader } from './ktx2-loader'
|
import { isKtx2Url, ktx2Loader, whenKtx2Ready } from './ktx2-loader'
|
||||||
import { getSceneTheme } from './scene-themes'
|
import { getSceneTheme } from './scene-themes'
|
||||||
|
|
||||||
export type RenderShading = 'solid' | 'rendered'
|
export type RenderShading = 'solid' | 'rendered'
|
||||||
@@ -308,8 +308,17 @@ async function loadPresetTexture(
|
|||||||
const existingPromise = textureLoadPromises.get(cacheKey)
|
const existingPromise = textureLoadPromises.get(cacheKey)
|
||||||
if (existingPromise) return existingPromise
|
if (existingPromise) return existingPromise
|
||||||
|
|
||||||
const promise = pickTextureLoader(resolvedPath)
|
// `.ktx2` loads wait for `detectSupport` (KTX2Loader.load throws before it) —
|
||||||
.loadAsync(resolvedPath)
|
// materials can be created while a capture canvas's renderer is still
|
||||||
|
// initializing, and failing here would cache the material permanently
|
||||||
|
// texture-less (white).
|
||||||
|
const load = isKtx2Url(resolvedPath)
|
||||||
|
? whenKtx2Ready().then(() =>
|
||||||
|
(ktx2Loader as unknown as THREE.TextureLoader).loadAsync(resolvedPath),
|
||||||
|
)
|
||||||
|
: textureLoader.loadAsync(resolvedPath)
|
||||||
|
|
||||||
|
const promise = load
|
||||||
.then((texture) => {
|
.then((texture) => {
|
||||||
applyTextureProperties(texture, props, slot)
|
applyTextureProperties(texture, props, slot)
|
||||||
setTextureCacheKey(texture, cacheKey)
|
setTextureCacheKey(texture, cacheKey)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import * as TSL from 'three/tsl'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* three runs at 0.185 but @types/three is pinned at 0.184: the 0.185 typings
|
||||||
|
* make tsgo's type inference allocate unboundedly (microsoft/typescript-go
|
||||||
|
* #2125 class) and OOM the machine. r185 renamed directionToColor /
|
||||||
|
* colorToDirection to packNormalToRGB / unpackRGBToNormal — re-export the new
|
||||||
|
* runtime names under the old names' signatures. Delete this file (and the
|
||||||
|
* @types/three pin) once tsgo handles the 0.185 types; the `typeof` references
|
||||||
|
* to the removed old names will fail the build as a reminder.
|
||||||
|
*/
|
||||||
|
export const packNormalToRGB = (TSL as unknown as { packNormalToRGB: typeof TSL.directionToColor })
|
||||||
|
.packNormalToRGB
|
||||||
|
export const unpackRGBToNormal = (
|
||||||
|
TSL as unknown as { unpackRGBToNormal: typeof TSL.colorToDirection }
|
||||||
|
).unpackRGBToNormal
|
||||||
@@ -79,6 +79,10 @@ type ViewerState = {
|
|||||||
|
|
||||||
unit: 'metric' | 'imperial'
|
unit: 'metric' | 'imperial'
|
||||||
setUnit: (unit: 'metric' | 'imperial') => void
|
setUnit: (unit: 'metric' | 'imperial') => void
|
||||||
|
/** True once the user explicitly picked a unit. Until then `unit` is a
|
||||||
|
* locale-derived default and is not persisted, so the default can keep
|
||||||
|
* tracking the browser locale across sessions. */
|
||||||
|
unitExplicit: boolean
|
||||||
|
|
||||||
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
||||||
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
||||||
@@ -165,6 +169,7 @@ type PersistedViewerState = Partial<
|
|||||||
| 'edges'
|
| 'edges'
|
||||||
| 'shadows'
|
| 'shadows'
|
||||||
| 'unit'
|
| 'unit'
|
||||||
|
| 'unitExplicit'
|
||||||
| 'levelMode'
|
| 'levelMode'
|
||||||
| 'wallMode'
|
| 'wallMode'
|
||||||
| 'projectPreferences'
|
| 'projectPreferences'
|
||||||
@@ -179,6 +184,63 @@ const UNITS = ['metric', 'imperial'] as const
|
|||||||
const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const
|
const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const
|
||||||
const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] as const
|
const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] as const
|
||||||
|
|
||||||
|
// Countries still on imperial/US customary units: United States, Liberia, Myanmar.
|
||||||
|
const IMPERIAL_REGIONS = ['US', 'LR', 'MM']
|
||||||
|
|
||||||
|
// IANA zones for those countries. The timezone tracks the OS clock (actual
|
||||||
|
// location), unlike navigator.language where en-US is a common default for
|
||||||
|
// users far outside the US.
|
||||||
|
const IMPERIAL_TIMEZONES = new Set([
|
||||||
|
'America/New_York',
|
||||||
|
'America/Detroit',
|
||||||
|
'America/Kentucky/Louisville',
|
||||||
|
'America/Kentucky/Monticello',
|
||||||
|
'America/Indiana/Indianapolis',
|
||||||
|
'America/Indiana/Vincennes',
|
||||||
|
'America/Indiana/Winamac',
|
||||||
|
'America/Indiana/Marengo',
|
||||||
|
'America/Indiana/Petersburg',
|
||||||
|
'America/Indiana/Vevay',
|
||||||
|
'America/Indiana/Tell_City',
|
||||||
|
'America/Indiana/Knox',
|
||||||
|
'America/Chicago',
|
||||||
|
'America/Menominee',
|
||||||
|
'America/North_Dakota/Center',
|
||||||
|
'America/North_Dakota/New_Salem',
|
||||||
|
'America/North_Dakota/Beulah',
|
||||||
|
'America/Denver',
|
||||||
|
'America/Boise',
|
||||||
|
'America/Phoenix',
|
||||||
|
'America/Los_Angeles',
|
||||||
|
'America/Anchorage',
|
||||||
|
'America/Juneau',
|
||||||
|
'America/Sitka',
|
||||||
|
'America/Metlakatla',
|
||||||
|
'America/Yakutat',
|
||||||
|
'America/Nome',
|
||||||
|
'America/Adak',
|
||||||
|
'Pacific/Honolulu',
|
||||||
|
'America/Puerto_Rico',
|
||||||
|
'Pacific/Guam',
|
||||||
|
'Africa/Monrovia', // Liberia
|
||||||
|
'Asia/Yangon', // Myanmar
|
||||||
|
'Asia/Rangoon', // Myanmar (legacy alias)
|
||||||
|
])
|
||||||
|
|
||||||
|
function detectDefaultUnit(): ViewerState['unit'] {
|
||||||
|
if (typeof navigator === 'undefined') return 'metric'
|
||||||
|
try {
|
||||||
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
if (timeZone) return IMPERIAL_TIMEZONES.has(timeZone) ? 'imperial' : 'metric'
|
||||||
|
// No timezone available: fall back to an explicit locale region subtag
|
||||||
|
// only (never maximize() — it turns a bare "en" into region US).
|
||||||
|
const region = new Intl.Locale(navigator.language).region
|
||||||
|
return region && IMPERIAL_REGIONS.includes(region) ? 'imperial' : 'metric'
|
||||||
|
} catch {
|
||||||
|
return 'metric'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pickString<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
function pickString<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||||
return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback
|
return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback
|
||||||
}
|
}
|
||||||
@@ -226,7 +288,9 @@ function normalizePersistedViewerState(value: unknown): PersistedViewerState {
|
|||||||
colorPreset: pickString<ColorPreset>(state.colorPreset, COLOR_PRESETS, 'clay'),
|
colorPreset: pickString<ColorPreset>(state.colorPreset, COLOR_PRESETS, 'clay'),
|
||||||
edges: pickString<EdgeMode>(state.edges, EDGE_MODES, 'soft'),
|
edges: pickString<EdgeMode>(state.edges, EDGE_MODES, 'soft'),
|
||||||
shadows: typeof state.shadows === 'boolean' ? state.shadows : true,
|
shadows: typeof state.shadows === 'boolean' ? state.shadows : true,
|
||||||
unit: pickString<ViewerState['unit']>(state.unit, UNITS, 'metric'),
|
unit: pickString<ViewerState['unit']>(state.unit, UNITS, detectDefaultUnit()),
|
||||||
|
unitExplicit:
|
||||||
|
typeof state.unit === 'string' && UNITS.includes(state.unit as ViewerState['unit']),
|
||||||
levelMode: pickString<ViewerState['levelMode']>(state.levelMode, LEVEL_MODES, 'stacked'),
|
levelMode: pickString<ViewerState['levelMode']>(state.levelMode, LEVEL_MODES, 'stacked'),
|
||||||
wallMode: pickString<ViewerState['wallMode']>(state.wallMode, WALL_MODES, 'up'),
|
wallMode: pickString<ViewerState['wallMode']>(state.wallMode, WALL_MODES, 'up'),
|
||||||
projectPreferences: normalizeProjectPreferences(state.projectPreferences),
|
projectPreferences: normalizeProjectPreferences(state.projectPreferences),
|
||||||
@@ -295,8 +359,9 @@ const useViewer = create<ViewerState>()(
|
|||||||
shadows: true,
|
shadows: true,
|
||||||
setShadows: (shadows) => set({ shadows }),
|
setShadows: (shadows) => set({ shadows }),
|
||||||
|
|
||||||
unit: 'metric',
|
unit: detectDefaultUnit(),
|
||||||
setUnit: (unit) => set({ unit }),
|
unitExplicit: false,
|
||||||
|
setUnit: (unit) => set({ unit, unitExplicit: true }),
|
||||||
|
|
||||||
levelMode: 'stacked',
|
levelMode: 'stacked',
|
||||||
setLevelMode: (mode) => set({ levelMode: mode }),
|
setLevelMode: (mode) => set({ levelMode: mode }),
|
||||||
@@ -431,7 +496,7 @@ const useViewer = create<ViewerState>()(
|
|||||||
colorPreset: state.colorPreset,
|
colorPreset: state.colorPreset,
|
||||||
edges: state.edges,
|
edges: state.edges,
|
||||||
shadows: state.shadows,
|
shadows: state.shadows,
|
||||||
unit: state.unit,
|
...(state.unitExplicit ? { unit: state.unit } : {}),
|
||||||
levelMode: state.levelMode,
|
levelMode: state.levelMode,
|
||||||
wallMode: state.wallMode,
|
wallMode: state.wallMode,
|
||||||
projectPreferences: state.projectPreferences,
|
projectPreferences: state.projectPreferences,
|
||||||
@@ -440,4 +505,17 @@ const useViewer = create<ViewerState>()(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Apply an authoritative country code (e.g. IP-derived by the host app) as
|
||||||
|
* the unit default. Stronger signal than the timezone heuristic used at store
|
||||||
|
* creation, but still a default: it never overrides an explicit user choice
|
||||||
|
* and is not persisted (the unit only sticks once the user touches the
|
||||||
|
* toggle). */
|
||||||
|
export function applyCountryUnitDefault(country: string | null | undefined) {
|
||||||
|
if (!country) return
|
||||||
|
const state = useViewer.getState()
|
||||||
|
if (state.unitExplicit) return
|
||||||
|
const unit = IMPERIAL_REGIONS.includes(country.toUpperCase()) ? 'imperial' : 'metric'
|
||||||
|
if (state.unit !== unit) useViewer.setState({ unit })
|
||||||
|
}
|
||||||
|
|
||||||
export default useViewer
|
export default useViewer
|
||||||
|
|||||||
Reference in New Issue
Block a user