diff --git a/apps/editor/package.json b/apps/editor/package.json index d60a2f7ab..f79e756e6 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,7 +21,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", - "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#483a080558293cd344170e66318230e2ac9b6ae8", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", diff --git a/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz deleted file mode 100644 index 4461b548c..000000000 Binary files a/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz and /dev/null differ diff --git a/bun.lock b/bun.lock index b63a089de..253820f6c 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", - "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#483a080558293cd344170e66318230e2ac9b6ae8", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", @@ -271,6 +271,7 @@ "@pascal-app/editor": "^1.0.0", "@pascal-app/viewer": "^1.0.0", "@pascal/typescript-config": "*", + "@react-three/test-renderer": "^9.1.0", "@types/bun": "^1.3.0", "@types/node": "^22.19.12", "@types/react": "^19.2.2", @@ -753,7 +754,7 @@ "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#40baf63", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-40baf63", "sha512-mb9i4IFq1c62BcAhtGJAKgJHBVQtFYySFW/LzkmnNfnWSOlGdUavoiR4f8cq5Y4xg6MTvGFX0ytO1Urk8wOPPQ=="], - "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sha512-nu/7x5xF1k0IYnduNu1rh9YJ9hZrmjaRUiXADcDqF82Kc+GLjZFrPRRHCci37QplREY2lqePIXBYfTOXCQczgg=="], + "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#483a080", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-483a080", "sha512-amY0DNHKXnjBGLyouD78sIKZ+AbQrX67gQEX2N21I4t9o1F07sObYnU77tLsCTc8TQdm/G7YNSWM1w1sEmwhVw=="], "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9", "sha512-X7Zg7wi0ghZRcTtbH5LF6xye493uSZ2ft3AmAQBtguBCU6VCwCexA7pdKDr5AnVxX/JRj2s6JSd/OX4aIZ9Y6Q=="], diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 69ac14c22..18333405b 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -2455,6 +2455,8 @@ export type ParamAction = { export type ParamGroup = { label: string fields: ParamField[] + /** Whether this inspector group is open when it is first rendered. */ + defaultExpanded?: boolean } export type ParamField = diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 786f28299..670751c2d 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1347,10 +1347,9 @@ function EditorContent({ // Load on mount, project switches, and explicit retry attempts. useEffect(() => { - void sceneLoadAttempt let cancelled = false - async function load() { + async function load(attempt: number) { isLoadingSceneRef.current = true setSceneLoadError(null) setHasLoadedInitialScene(false) @@ -1364,7 +1363,7 @@ function EditorContent({ let failed = false try { const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() - if (!cancelled) { + if (!cancelled && attempt === sceneLoadAttempt) { applySceneGraphToEditor(sceneGraph) setIsViewerSceneReady(false) setSceneReadyKey((key) => key + 1) @@ -1390,7 +1389,7 @@ function EditorContent({ } } - load() + load(sceneLoadAttempt) return () => { cancelled = true diff --git a/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx b/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx index bab0b2ace..066fcf707 100644 --- a/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx @@ -72,8 +72,9 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode )} {parametrics.groups.map((group, gi) => ( []} - key={`group-${gi}`} + key={`${nodeType}-${group.label}-${gi}`} nodeIds={nodeIds} nodeType={nodeType} parametrics={parametrics} @@ -88,12 +89,14 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode } function MultiGroupFields({ + defaultExpanded, title, fields, nodeIds, nodeType, parametrics, }: { + defaultExpanded?: boolean title: string fields: ParamField[] nodeIds: AnyNodeId[] @@ -108,7 +111,7 @@ function MultiGroupFields({ ) if (genericFields.length === 0 || !anyVisible) return null return ( - + {genericFields.map((field, fi) => { if ( String(field.key) === 'height' && diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index b1bc2132e..edb960a73 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -157,7 +157,11 @@ export function ParametricInspector({ width={320} > {parametrics.groups.map((group, gi) => ( - + {group.fields.map((field, fi) => ( 0)) && ( - + {canMove && ( } label="Move" onClick={handleMove} /> )} diff --git a/packages/nodes/package.json b/packages/nodes/package.json index d989c0c3f..cb74f44f0 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -39,6 +39,7 @@ "@pascal-app/editor": "^1.0.0", "@pascal-app/viewer": "^1.0.0", "@pascal/typescript-config": "*", + "@react-three/test-renderer": "^9.1.0", "@types/bun": "^1.3.0", "@types/node": "^22.19.12", "@types/react": "^19.2.2", diff --git a/packages/nodes/src/duct-segment/draw-plan.test.ts b/packages/nodes/src/duct-segment/draw-plan.test.ts index 5e9502114..3effbcc3d 100644 --- a/packages/nodes/src/duct-segment/draw-plan.test.ts +++ b/packages/nodes/src/duct-segment/draw-plan.test.ts @@ -1,8 +1,28 @@ import { expect, test } from 'bun:test' import { DuctSegmentNode, useScene } from '@pascal-app/core' -import { planDuctDraw } from './tool' +import { ductSurfaceClearanceM, planDuctDraw } from './tool' const profile = { shape: 'round' as const, diameter: 6, width: 12, height: 8 } + +test('keeps rectangular and oval ducts outside wall faces using their largest dimension', () => { + expect(ductSurfaceClearanceM({ shape: 'round', diameter: 6, width: 12, height: 8 })).toBeCloseTo( + 0.0762, + ) + expect( + ductSurfaceClearanceM({ shape: 'rect', diameter: 6, width: 14, height: 8 }, true), + ).toBeCloseTo(0.1878) + expect( + ductSurfaceClearanceM({ shape: 'oval', diameter: 6, width: 14, height: 8 }, true), + ).toBeCloseTo(0.1878) +}) + +test('floor and ceiling clearance uses the height of rectangular and oval ducts', () => { + for (const shape of ['rect', 'oval'] as const) { + expect(ductSurfaceClearanceM({ shape, diameter: 6, width: 14, height: 8 })).toBeCloseTo(0.1016) + expect(ductSurfaceClearanceM({ shape, diameter: 6, width: 20, height: 8 })).toBeCloseTo(0.1016) + } +}) + test('a short existing run cannot silently lose its required elbow', () => { const node = DuctSegmentNode.parse({ path: [ diff --git a/packages/nodes/src/duct-segment/parametrics.ts b/packages/nodes/src/duct-segment/parametrics.ts index 8406d681e..4a4fbfec5 100644 --- a/packages/nodes/src/duct-segment/parametrics.ts +++ b/packages/nodes/src/duct-segment/parametrics.ts @@ -1,6 +1,7 @@ import { type DuctFittingNode, type ParametricDescriptor, useScene } from '@pascal-app/core' import { Vector3 } from 'three' import { getDuctFittingPorts } from '../duct-fitting/ports' +import { findAutomaticRunEndCapIds } from '../shared/automatic-run-end-cap' import { fittingDeletionPlansForRun } from '../shared/fitting-deletion-cleanup' import { rollToContinueAcrossElbow } from './geometry' import type { DuctSegmentNode } from './schema' @@ -105,10 +106,12 @@ export const ductSegmentParametrics: ParametricDescriptor = { fittingDeletionPlansForRun(duct, nodes, requestedDeleteIds, true).flatMap( (plan) => plan.updates, ), - onDeleteCascade: (duct, nodes, _pendingDeleteIds, requestedDeleteIds) => - fittingDeletionPlansForRun(duct, nodes, requestedDeleteIds, false).flatMap((plan) => + onDeleteCascade: (duct, nodes, _pendingDeleteIds, requestedDeleteIds) => [ + ...findAutomaticRunEndCapIds(duct.id, nodes, 'duct-fitting'), + ...fittingDeletionPlansForRun(duct, nodes, requestedDeleteIds, false).flatMap((plan) => plan.deleteFitting ? [plan.fittingId, ...plan.cascadeDeleteIds] : [], ), + ], trailingSection: () => import('../shared/run-hanger-inspector'), groups: [ { diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index 12691ff13..4ead26445 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -85,6 +85,7 @@ import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry' */ const DUCT_DIAMETERS_IN = [4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20] as const const BODY_SNAP_RADIUS_M = 0.35 +const DUCT_WALL_STANDOFF_M = 0.01 /** Angle step (radians) for the XZ angle lock — 45°. */ /** @@ -188,6 +189,18 @@ type DraftProfile = { height: number } +export function ductSurfaceClearanceM(profile: DraftProfile, wall = false): number { + return ( + runSectionHalfSizeM( + profile.shape === 'round' + ? profile.diameter + : wall + ? Math.max(profile.width, profile.height) + : profile.height, + ) + (wall && profile.shape !== 'round' ? DUCT_WALL_STANDOFF_M : 0) + ) +} + /** * Profile to inherit when the segment start snaps onto `port` — joining * means continuing that thing: a rect trunk end keeps its W×H, a round @@ -553,13 +566,7 @@ const DuctSegmentTool = () => { findBody: (point) => findNearestRunBody3D(point, BODY_SNAP_RADIUS_M, { levelId: activeLevelId ?? undefined }), surfaceClearance: (surface) => - surface - ? runSectionHalfSizeM( - profileRef.current.shape === 'round' - ? profileRef.current.diameter - : profileRef.current.height, - ) - : 0, + surface ? ductSurfaceClearanceM(profileRef.current, surface.kind === 'wall') : 0, minimumSegmentLength: 0.08, inheritFromConnection: ({ port }) => { if (!port) return @@ -593,9 +600,7 @@ const DuctSegmentTool = () => { node.path[0]!, node.path.at(-1)!, surfaceTarget, - profileRef.current.shape === 'round' - ? runSectionHalfSizeM(profileRef.current.diameter) - : runSectionHalfSizeM(profileRef.current.height), + ductSurfaceClearanceM(profileRef.current, true), ) : undefined return { ...node, wallAttachment } diff --git a/packages/nodes/src/pipe-segment/parametrics.ts b/packages/nodes/src/pipe-segment/parametrics.ts index 9f2adcd43..33e165a83 100644 --- a/packages/nodes/src/pipe-segment/parametrics.ts +++ b/packages/nodes/src/pipe-segment/parametrics.ts @@ -1,4 +1,5 @@ import type { ParametricDescriptor } from '@pascal-app/core' +import { findAutomaticRunEndCapIds } from '../shared/automatic-run-end-cap' import { fittingDeletionPlansForRun } from '../shared/fitting-deletion-cleanup' import type { PipeSegmentNode } from './schema' @@ -15,10 +16,12 @@ export const pipeSegmentParametrics: ParametricDescriptor = { fittingDeletionPlansForRun(pipe, nodes, requestedDeleteIds, true).flatMap( (plan) => plan.updates, ), - onDeleteCascade: (pipe, nodes, _pendingDeleteIds, requestedDeleteIds) => - fittingDeletionPlansForRun(pipe, nodes, requestedDeleteIds, false).flatMap((plan) => + onDeleteCascade: (pipe, nodes, _pendingDeleteIds, requestedDeleteIds) => [ + ...findAutomaticRunEndCapIds(pipe.id, nodes, 'pipe-fitting'), + ...fittingDeletionPlansForRun(pipe, nodes, requestedDeleteIds, false).flatMap((plan) => plan.deleteFitting ? [plan.fittingId, ...plan.cascadeDeleteIds] : [], ), + ], trailingSection: () => import('../shared/run-hanger-inspector'), groups: [ { diff --git a/packages/nodes/src/shared/automatic-run-end-cap.test.ts b/packages/nodes/src/shared/automatic-run-end-cap.test.ts index 01a7be432..7a8c80ac4 100644 --- a/packages/nodes/src/shared/automatic-run-end-cap.test.ts +++ b/packages/nodes/src/shared/automatic-run-end-cap.test.ts @@ -4,6 +4,7 @@ import { DuctSegmentNode, loadPlugin, nodeRegistry, + PipeFittingNode, PipeSegmentNode, } from '@pascal-app/core' import { getDuctFittingPorts } from '../duct-fitting/ports' @@ -12,6 +13,7 @@ import { getPipeFittingPorts } from '../pipe-fitting/ports' import { createDuctRunEndCap, createPipeRunEndCap, + findAutomaticRunEndCapIds, findMatedRunEndCapIds, isRunEndCapPort, planRunEndCapFollowUpdates, @@ -154,6 +156,27 @@ describe('automatic run end caps', () => { ).toEqual([firstCap.id]) }) + test('finds automatic caps by run owner without matching manual caps', () => { + const pipe = PipeSegmentNode.parse({ + path: [ + [0, 1, 0], + [3, 1, 0], + ], + }) + const automatic = createPipeRunEndCap(pipe)! + const manual = PipeFittingNode.parse({ + fittingType: 'end-cap', + metadata: {}, + }) + const nodes = { + [pipe.id]: pipe, + [automatic.id]: automatic, + [manual.id]: manual, + } as Record + + expect(findAutomaticRunEndCapIds(pipe.id, nodes, 'pipe-fitting')).toEqual([automatic.id]) + }) + test.each([ [ 'duct', diff --git a/packages/nodes/src/shared/automatic-run-end-cap.ts b/packages/nodes/src/shared/automatic-run-end-cap.ts index 27011f0ce..2186ea3c0 100644 --- a/packages/nodes/src/shared/automatic-run-end-cap.ts +++ b/packages/nodes/src/shared/automatic-run-end-cap.ts @@ -167,6 +167,23 @@ export function findMatedRunEndCapIds( return ids } +export function findAutomaticRunEndCapIds( + runId: AnyNodeId, + nodes: Readonly>, + fittingKind: 'duct-fitting' | 'pipe-fitting', +): AnyNodeId[] { + return Object.values(nodes).flatMap((node) => { + if ( + !node || + node.type !== fittingKind || + node.fittingType !== 'end-cap' || + node.metadata[END_CAP_OWNER_ID_KEY] !== runId + ) + return [] + return [node.id] + }) +} + export function planRunEndCapFollowUpdates( originalRun: DuctSegmentNode | PipeSegmentNode, nextRun: DuctSegmentNode | PipeSegmentNode, diff --git a/packages/nodes/src/shared/distribution-run-tool.test.tsx b/packages/nodes/src/shared/distribution-run-tool.test.tsx new file mode 100644 index 000000000..eaef1626e --- /dev/null +++ b/packages/nodes/src/shared/distribution-run-tool.test.tsx @@ -0,0 +1,121 @@ +import { expect, test } from 'bun:test' +import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core' +import { useEditor, useInteractionScope } from '@pascal-app/editor' +import { useThree } from '@react-three/fiber' +import { act, create } from '@react-three/test-renderer' +import { useDistributionRunTool } from './distribution-run-tool' + +test.each([ + 'duct-segment', + 'pipe-segment', +] as const)('%s releases its wall plane after an outward commit and retains it along the wall', async (toolName) => { + const previousScene = useScene.getState() + const previousEditor = useEditor.getState() + const previousScope = useInteractionScope.getState() + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + const previousActEnvironment = Object.getOwnPropertyDescriptor( + globalThis, + 'IS_REACT_ACT_ENVIRONMENT', + ) + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + value: true, + configurable: true, + writable: true, + }) + if (!previousWindow) + Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }) + const wall = WallNode.parse({ parentId: 'level_test', start: [0, 0], end: [10, 0] }) + let tool: ReturnType | undefined + const commits: Array<[number, number, number]> = [] + let acceptCommit = true + function Host() { + const { gl } = useThree() + gl.domElement.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect + tool = useDistributionRunTool({ + active: true, + levelId: 'level_test', + toolName, + getPorts: () => [], + findBody: () => null, + surfaceClearance: () => 0.1, + commit: ({ end }) => { + if (!acceptCommit) return null + commits.push(end) + return { nextStart: end, nextConnection: { port: null, body: null } } + }, + }) + return null + } + let renderer: Awaited> | undefined + const wallEvent = (x: number): GridEvent => ({ + position: [x, 1, 0], + localPosition: [x, 1, 0], + surfaceNormal: [0, 0, 1], + surfaceHit: { + kind: 'wall', + hostId: wall.id, + levelId: 'level_test', + face: 'side', + side: 'front', + }, + nativeEvent: {} as GridEvent['nativeEvent'], + }) + try { + useScene.setState({ nodes: { [wall.id]: wall } }) + useEditor.setState({ snappingModeByContext: { wall: 'off', polygon: 'off', item: 'off' } }) + renderer = await create() + await act(async () => { + emitter.emit('grid:click', wallEvent(1)) + }) + await act(async () => { + emitter.emit('grid:click', wallEvent(2)) + }) + expect(commits).toEqual([[2, 1, 0.1]]) + await act(async () => { + emitter.emit('grid:move', { + position: [3, 0, 4], + localPosition: [3, 0, 4], + localRay: { origin: [3, 2, 2.1], direction: [0, -0.5, -1] }, + nativeEvent: {} as GridEvent['nativeEvent'], + }) + }) + expect(tool?.cursor?.[2]).toBeCloseTo(0.1) + await act(async () => { + tool?.onDirectionSelect([0, 0, 1]) + }) + const outward: GridEvent = { + position: [2, 0, 2.1], + localPosition: [2, 0, 2.1], + localRay: { origin: [2, 3, 2.1], direction: [0, -1, 0] }, + nativeEvent: {} as GridEvent['nativeEvent'], + } + acceptCommit = false + await act(async () => { + emitter.emit('grid:click', outward) + }) + expect(commits).toHaveLength(1) + acceptCommit = true + await act(async () => { + emitter.emit('grid:click', outward) + }) + expect(commits[1]?.[2]).toBeCloseTo(2.1) + await act(async () => { + emitter.emit('grid:move', { + ...outward, + localRay: { origin: [4, 3, 2.1], direction: [0, -1, 0] }, + }) + }) + expect(tool?.cursor).toEqual([4, 1, 2.1]) + expect(tool?.surfaceTarget).toBeNull() + } finally { + await renderer?.unmount() + useScene.setState(previousScene, true) + useEditor.setState(previousEditor, true) + useInteractionScope.setState(previousScope, true) + if (!previousWindow) Reflect.deleteProperty(globalThis, 'window') + if (previousActEnvironment) + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment) + else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT') + } +}) diff --git a/packages/nodes/src/shared/distribution-run-tool.tsx b/packages/nodes/src/shared/distribution-run-tool.tsx index ef206c5bd..d746463b3 100644 --- a/packages/nodes/src/shared/distribution-run-tool.tsx +++ b/packages/nodes/src/shared/distribution-run-tool.tsx @@ -74,6 +74,34 @@ type ResolvedRunPoint = RunConnection & { directionMode: RunDirectionMode } +type RunWallSurfaceTarget = Extract + +export function shouldLockRunWallSurface( + target: RunSurfaceTarget | null | undefined, + connection: RunConnection, +): target is RunWallSurfaceTarget { + return target?.kind === 'wall' && !connection.port && !connection.body +} + +export function isSameRunWallSurface( + target: RunWallSurfaceTarget, + hit: + | { + kind?: string + hostId?: string + face?: string + side?: string + } + | undefined, +): boolean { + return ( + hit?.kind === 'wall' && + hit.hostId === target.hostId && + hit.face === 'side' && + hit.side === target.side + ) +} + const UP: RunPoint = [0, 1, 0] const X_AXIS: RunPoint = [1, 0, 0] const Z_AXIS: RunPoint = [0, 0, 1] @@ -525,6 +553,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const refreshCursorRef = useRef<() => void>(() => {}) const lastClientYRef = useRef(null) const lastResolvedRef = useRef(null) + const lockedWallTargetRef = useRef(null) const forcedDirectionRef = useRef(null) const hoveredDirectionRef = useRef(null) const lengthInputRef = useRef('') @@ -593,18 +622,33 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const hit = surfacePointFromEvent(event, adapter.levelId) const currentStart = startRef.current const previous = lastResolvedRef.current - // A wall is only an attachment candidate, not a constraint for the - // whole run. Once the ray leaves the wall, continue on a horizontal - // plane through the start point so the user can route freely at the - // same elevation (or use angle lock for a deliberate diagonal). - const working = - !event.surfaceHit && previous?.surfaceTarget?.kind === 'wall' && currentStart + const lockedWallTarget = currentStart ? lockedWallTargetRef.current : null + const lockedWallHit = + lockedWallTarget && isSameRunWallSurface(lockedWallTarget, event.surfaceHit) + const working = lockedWallTarget + ? { + ...lockedWallTarget.frame, + origin: lockedWallTarget.frame.origin.map( + (value, index) => + value + + lockedWallTarget.frame.normal[index]! * + (adapter.surfaceClearance?.(lockedWallTarget) ?? 0), + ) as RunPoint, + } + : !event.surfaceHit && previous?.surfaceTarget?.kind === 'wall' && currentStart ? createRunSurfaceFrame(currentStart, UP) : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null)) const hasSurface = !!event.surfaceHit || !working || !event.localRay - const target = hasSurface ? hit.target : null + const target = lockedWallTarget ?? (hasSurface ? hit.target : null) + const hitForCursor = lockedWallTarget + ? lockedWallHit + ? { point: hit.point, frame: lockedWallTarget.frame } + : null + : hasSurface + ? { point: hit.point, frame: hit.frame } + : null const resolved = resolveRunCursorPlane({ - hit: hasSurface ? { point: hit.point, frame: hit.frame } : null, + hit: hitForCursor, working, ray: event.localRay, fallback: previous?.point ?? currentStart ?? hit.point, @@ -795,7 +839,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { resolved = { ...resolved, frame: { ...lastResolvedRef.current.frame, origin: resolved.point }, - surfaceTarget: null, + surfaceTarget: lockedWallTargetRef.current, } lastResolvedRef.current = resolved const currentStart = startRef.current @@ -871,6 +915,26 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { setValidationMessage('Fitting clearance is too small for this connection') return } + const lockedWallTarget = lockedWallTargetRef.current + if (lockedWallTarget) { + const offset = result.nextStart.map( + (value, index) => value - lockedWallTarget.frame.origin[index]!, + ) + const clearance = configRef.current.surfaceClearance?.(lockedWallTarget) ?? 0 + if (Math.abs(dotRun(offset, lockedWallTarget.frame.normal) - clearance) > 1e-4) { + lockedWallTargetRef.current = null + lastResolvedRef.current = { + point: result.nextStart, + frame: createRunSurfaceFrame(result.nextStart), + surfaceTarget: null, + snapped: null, + directionMode: 'free', + port: null, + body: null, + } + clearPlacementSurface() + } + } triggerSFX('sfx:item-place') startRef.current = result.nextStart setStart(result.nextStart) @@ -925,11 +989,14 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { const resolved = applyTypedLength(resolvePoint(event)) updateCursor(resolved) if (!currentStart) { - triggerSFX('sfx:grid-snap') const connection = { port: resolved.port, body: resolved.port ? null : resolved.body, } + if (shouldLockRunWallSurface(resolved.surfaceTarget, connection)) { + lockedWallTargetRef.current = resolved.surfaceTarget + } + triggerSFX('sfx:grid-snap') startConnectionRef.current = connection configRef.current.inheritFromConnection?.(connection) startRef.current = resolved.point @@ -1031,6 +1098,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { } const onCancel = () => { + lockedWallTargetRef.current = null clearDrawAlignment() if (!startRef.current) return markToolCancelConsumed() @@ -1081,6 +1149,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) { window.removeEventListener('keyup', onKeyUp) altAnchorRef.current = null lastPointerRef.current = null + lockedWallTargetRef.current = null refreshCursorRef.current = () => {} clearPlacementSurface() clearDrawAlignment() diff --git a/packages/nodes/src/shared/fitting-deletion-cleanup.test.ts b/packages/nodes/src/shared/fitting-deletion-cleanup.test.ts index 36a6bf6a8..57394d0c0 100644 --- a/packages/nodes/src/shared/fitting-deletion-cleanup.test.ts +++ b/packages/nodes/src/shared/fitting-deletion-cleanup.test.ts @@ -15,6 +15,7 @@ import { pipeFittingDefinition } from '../pipe-fitting/definition' import { getPipeFittingPorts } from '../pipe-fitting/ports' import { pipeSegmentDefinition } from '../pipe-segment/definition' import { PipeSegmentNode } from '../pipe-segment/schema' +import { createDuctRunEndCap, createPipeRunEndCap } from './automatic-run-end-cap' type RafFn = (callback: (time: number) => void) => number ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( @@ -109,6 +110,62 @@ function expectRunStartsOnPorts( } describe('fitting cleanup when a connected run is deleted', () => { + test('deleting a duct removes its automatic end cap but keeps a manual cap', () => { + withDistributionDefinitions(() => { + const duct = DuctSegmentNode.parse({ + ...ductSegmentDefinition.defaults(), + id: 'duct-segment_end-cap-delete', + path: [ + [0, 1, 0], + [3, 1, 0], + ], + }) + const automaticCap = createDuctRunEndCap(duct)! + const manualCap = DuctFittingNode.parse({ + ...ductFittingDefinition.defaults(), + id: 'duct-fitting_manual-end-cap', + fittingType: 'end-cap', + }) + useScene.setState({ + nodes: { + [duct.id]: duct, + [automaticCap.id]: automaticCap, + [manualCap.id]: manualCap, + }, + rootNodeIds: [duct.id, automaticCap.id, manualCap.id], + readOnly: false, + } as never) + + useScene.getState().deleteNode(duct.id) + + expect(useScene.getState().nodes[automaticCap.id]).toBeUndefined() + expect(useScene.getState().nodes[manualCap.id]).toBeDefined() + }) + }) + + test('deleting a pipe removes its automatic end cap', () => { + withDistributionDefinitions(() => { + const pipe = PipeSegmentNode.parse({ + ...pipeSegmentDefinition.defaults(), + id: 'pipe-segment_end-cap-delete', + path: [ + [0, 1, 0], + [3, 1, 0], + ], + }) + const automaticCap = createPipeRunEndCap(pipe)! + useScene.setState({ + nodes: { [pipe.id]: pipe, [automaticCap.id]: automaticCap }, + rootNodeIds: [pipe.id, automaticCap.id], + readOnly: false, + } as never) + + useScene.getState().deleteNode(pipe.id) + + expect(useScene.getState().nodes[automaticCap.id]).toBeUndefined() + }) + }) + test('downgrades a duct cross to a tee and keeps every surviving collar mated', () => { withDistributionDefinitions(() => { const fitting = DuctFittingNode.parse({ diff --git a/packages/nodes/src/shared/run-cursor.test.ts b/packages/nodes/src/shared/run-cursor.test.ts index 5a4bdb109..03071def3 100644 --- a/packages/nodes/src/shared/run-cursor.test.ts +++ b/packages/nodes/src/shared/run-cursor.test.ts @@ -1,8 +1,64 @@ import { describe, expect, test } from 'bun:test' -import { createRunSurfaceFrame } from './distribution-run-tool' +import type { AnyNodeId } from '@pascal-app/core' +import { + createRunSurfaceFrame, + isSameRunWallSurface, + shouldLockRunWallSurface, +} from './distribution-run-tool' import { intersectRunPlane, resolveRunCursorPlane } from './run-cursor' describe('surface-first run cursor', () => { + test('matches only the wall host and face that started the draft', () => { + const target = { + kind: 'wall' as const, + levelId: 'level-1' as AnyNodeId, + hostId: 'wall-1' as AnyNodeId, + side: 'front' as const, + frame: createRunSurfaceFrame([0, 0, 0], [0, 0, 1]), + bounds: { minU: -1, maxU: 1, minV: -1, maxV: 1 }, + } + + expect( + isSameRunWallSurface(target, { + kind: 'wall', + hostId: target.hostId, + face: 'side', + side: 'front', + }), + ).toBe(true) + expect( + isSameRunWallSurface(target, { + kind: 'wall', + hostId: target.hostId, + face: 'side', + side: 'back', + }), + ).toBe(false) + expect( + isSameRunWallSurface(target, { + kind: 'wall', + hostId: 'wall-2', + face: 'side', + side: 'front', + }), + ).toBe(false) + }) + + test('locks a wall only when the start is not snapped to a body or port', () => { + const target = { + kind: 'wall' as const, + levelId: 'level-1' as AnyNodeId, + hostId: 'wall-1' as AnyNodeId, + side: 'front' as const, + frame: createRunSurfaceFrame([0, 0, 0], [0, 0, 1]), + bounds: { minU: 0, maxU: 1, minV: 0, maxV: 1 }, + } + + expect(shouldLockRunWallSurface(target, { port: null, body: null })).toBe(true) + expect(shouldLockRunWallSurface(target, { port: {} as never, body: null })).toBe(false) + expect(shouldLockRunWallSurface(target, { port: null, body: {} as never })).toBe(false) + }) + test('reacquires either ceiling face from free space with duct or pipe clearance', () => { for (const side of [-1, 1]) { for (const clearance of [0.0254, 0.1016]) {