diff --git a/DECISIONS.md b/DECISIONS.md index 66244949ef..7683902239 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -67,6 +67,19 @@ Each entry: decision · why · enforced by · revisit when. Detail and examples **E-016 Plugins own their agent tools (owner, 2026-10-10).** A plugin's agent tools ship with the plugin, not in core: contracts as a `PluginAgentTools` in its `./agent-tools` export (zod only, like `core/agent-tools`, and versioned by the plugin), pure operations as an `AgentOperations` in `./agent-operations`, browser execution and cards as a `PluginChatExtension`. The `Plugin` manifest does not change (E-014), and one contract still serves both surfaces. Contract annotations are hints: the host decides which tools are offered, to whom, and with what authority. These are reviewed, trusted contributions, not a sandbox. · Why: a plugin with dozens of specialist tools keeps them versioned with its own code, and installing it extends every agent the way a connector does. · Enforced: `core/src/agent-tools/contracts-purity.test.ts` (the rule each plugin entry follows), `mcp/src/tools/shared-tools.test.ts` (a plugin's operation tool through a real client), the hosted parity test. · See `plugin-authoring.md`, `agent-surfaces.md`. · Revisit: when plugins are loaded without review. +**E-017 Additive node editing and surface contracts (2026-10-16).** Surface boundaries, +connected movement, primary inspector predicates, independent child movement and +preserved level/elevation are optional contributions on `NodeDefinition` and its +existing capability types. They do not add fields to the v1 `Plugin` manifest. +Boundary providers own their geometry and coordinates; framework consumers dispatch +through the registry. Connected-move providers return pure patches, previewed with +live overrides and committed once per gesture. Inspector predicates select an ID +without subscribing the card to whole nodes. `metadata.roomBoundary === false` is +the explicit opt-out from room topology. Shared run-authoring tools are exported by +`editor/distribution`, never by the built-in plugin. · Why: external node packs need +the same authoring behavior without depending on built-in kinds or private plugin +shapes. · Contract: `wiki/architecture/plugin-authoring.md`. + ## Testing **T-001 (2026-10-01, owner).** A test protects an observable result, a persisted or wire contract, or an explicit architecture/performance invariant — and tolerates an equivalent implementation. Therefore: test through package exports; no source-grep, prose-pinning or import-spelling tests (use Biome rules); no cross-package `src/` imports in tests; no `mock.module` that forces sibling files to re-spawn `bun test`; no real sleeps (inject a clock); no tests named after review rounds or bots; refactor scaffolding (parity tables, "matches main" goldens, frozen copies of old code) is deleted in the PR that lands the refactor; performance claims live in `__bench__` / bench lanes, not unit gates; a bug fix is one repro in the feature's existing file, not a new `*-audit*` file; a test that needs an opt-in flag must have a lane that sets it, or go. "Internal" is not the deletion criterion: core's import boundary, the writers allowlist and the migration thresholds pin internals with external consequences and stay. · Why: 231k test lines (82k of them fixtures) had become the main cost of every change for people and agents alike. · Enforced: review; Biome rules as they land. diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 77fd6e1d02..441cb75f1d 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -8,6 +8,11 @@ @source "../../../node_modules/@pascal-app/plugin-streetscape/src"; @source "../../../node_modules/@pascal-app/plugin-environment/src"; +@source "../../../node_modules/@pascal-app/plugin-landscape/src"; +@source "../../../node_modules/@pascal-app/plugin-bath-space/src"; +@source "../../../node_modules/@pascal-app/plugin-pool/src"; +@source "../../../node_modules/@webxr/plugin/src"; + @custom-variant dark (&:is(.dark *)); @theme { @@ -171,6 +176,27 @@ scrollbar-width: none; /* Firefox */ } +* { + scrollbar-width: thin; + scrollbar-color: gray transparent; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; + background: transparent; +} + +*::-webkit-scrollbar-track, +*::-webkit-scrollbar-track-piece, +*::-webkit-scrollbar-corner { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: gray; +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/apps/editor/components/pool-section-banner.tsx b/apps/editor/components/pool-section-banner.tsx new file mode 100644 index 0000000000..711f1f069c --- /dev/null +++ b/apps/editor/components/pool-section-banner.tsx @@ -0,0 +1,18 @@ +'use client' + +import { lazy, Suspense } from 'react' + +const PoolSectionBar = lazy(async () => { + const pool = await import('@pascal-app/plugin-pool') + return { default: pool.PoolSectionBar } +}) + +export function PoolSectionBanner() { + return ( +
+ + + +
+ ) +} diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 9a1263f3ef..1622d61c43 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -15,9 +15,14 @@ import { Hammer, Layers, Package, Palette, Settings } from 'lucide-react' import Image from 'next/image' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { PoolSectionBanner } from '@/components/pool-section-banner' import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard' import { useLocalArtifactStore } from '@/lib/local-artifact-store' -import { type PersistedSceneGraph, sceneGraphSignature } from '@/lib/scene-signature' +import { + isPendingSceneGraphEcho, + type PersistedSceneGraph, + sceneGraphSignature, +} from '@/lib/scene-signature' import { useThumbnailAutosave } from '@/lib/use-thumbnail-autosave' import { BuildTab } from './build-tab' import { EditorNavbar } from './editor-navbar' @@ -187,6 +192,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { // store must never overwrite a populated server copy. const serverNodeCountRef = useRef(meta.nodeCount) const lastRemoteGraphJsonRef = useRef(null) + const pendingGraphSignaturesRef = useRef(new Set()) const suppressRemoteSaveUntilRef = useRef(0) const [conflict, setConflict] = useState(false) const [saveError, setSaveError] = useState(null) @@ -249,6 +255,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { return } + pendingGraphSignaturesRef.current.add(graphJson) try { const response = await fetch(`/api/scenes/${meta.id}`, { method: 'PUT', @@ -285,11 +292,15 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { } const next = (await response.json()) as SceneMeta - versionRef.current = next.version - serverNodeCountRef.current = next.nodeCount + if (next.version >= versionRef.current) { + versionRef.current = next.version + serverNodeCountRef.current = next.nodeCount + } setSaveError(null) } catch (error) { setSaveError(error instanceof Error ? error.message : 'Save failed') + } finally { + pendingGraphSignaturesRef.current.delete(graphJson) } }, [meta.id, meta.name], @@ -310,7 +321,11 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { versionRef.current = payload.version serverNodeCountRef.current = countGraphNodes(payload.graph) - lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph) + const signature = sceneGraphSignature(payload.graph) + // SSE can deliver our save before its HTTP response. Reapplying that + // graph would reset tools and selection while the user keeps editing. + if (isPendingSceneGraphEcho(payload.graph, pendingGraphSignaturesRef.current)) return + lastRemoteGraphJsonRef.current = signature suppressRemoteSaveUntilRef.current = Date.now() + 2500 applySceneGraphToEditor(payload.graph) setConflict(false) @@ -381,6 +396,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { projectId={meta.projectId ?? 'default'} projectName={meta.name} sidebarTabs={SIDEBAR_TABS} + viewerBanner={} viewerToolbarLeft={} viewerToolbarRight={ [bonesPlugin]) registerEditorHostPanel({ ...bonesHostPanel, defaultInstalled: false }) extendPluginDiscovery(async () => [mintPlugin]) registerEditorHostPanel(mintHostPanel) -extendPluginDiscovery(async () => [poolPlugin]) -registerEditorHostPanel(poolHostPanel) -extendPluginDiscovery(async () => [streetscapePlugin]) -// The upstream manifest still names 'Pascal' as creator; credit the author. -registerEditorHostPanel({ - ...streetscapeHostPanel, - creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' }, +extendPluginDiscovery(async () => { + const { poolHostPanel, poolPlugin } = await import('@pascal-app/plugin-pool') + registerEditorHostPanel(poolHostPanel) + return [poolPlugin] +}) +extendPluginDiscovery(async () => { + const { landscapeHostPanel, landscapePlugin } = await import('@pascal-app/plugin-landscape') + registerEditorHostPanel(landscapeHostPanel) + return [landscapePlugin] +}) +extendPluginDiscovery(async () => { + const { bathSpaceHostPanel, bathSpacePlugin } = await import('@pascal-app/plugin-bath-space') + registerEditorHostPanel(bathSpaceHostPanel) + return [bathSpacePlugin] +}) +extendPluginDiscovery(async () => { + const { streetscapeHostPanel, streetscapePlugin } = await import('@pascal-app/plugin-streetscape') + registerEditorHostPanel({ + ...streetscapeHostPanel, + creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' }, + }) + return [streetscapePlugin] }) extendPluginDiscovery(async () => [webXRPlugin]) registerEditorHostPanel(webXRHostPanel) diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts index 70ed0b687c..00fa489e13 100644 --- a/apps/editor/lib/graph-schema.test.ts +++ b/apps/editor/lib/graph-schema.test.ts @@ -34,6 +34,26 @@ test('accepts a graph containing a plugin node kind', () => { expect(apiGraphSchema.safeParse(graph).success).toBe(true) }) +test('accepts namespaced attachment slots while validating plugin asset URLs', () => { + const node = pluginTree({ slotId: `wall-point:${TREE_ID}` }) + expect(apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: node })).success).toBe(true) + + for (const url of [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'javascript:alert', + 'file:secret', + 'data:text', + ]) { + expect( + apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: { ...node, thumbnail: url } })).success, + ).toBe(false) + expect( + apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: { ...node, slotId: url } })).success, + ).toBe(false) + } +}) + test('accepts a builtin container whose children include a plugin node id', () => { const graph = buildGraph({ [LEVEL_ID]: level([TREE_ID]), [TREE_ID]: pluginTree() }, [LEVEL_ID]) diff --git a/apps/editor/lib/graph-schema.ts b/apps/editor/lib/graph-schema.ts index db537b46c2..7c32292786 100644 --- a/apps/editor/lib/graph-schema.ts +++ b/apps/editor/lib/graph-schema.ts @@ -64,8 +64,7 @@ type ScanResult = { path: (string | number)[]; reason: 'url' | 'too-complex' } /** * First URL-shaped string `AssetUrl` rejects, or a breach of the walk bounds. * - * The node's own `type` is exempt: a namespaced kind (`trees:tree`) is - * scheme-shaped by construction, and the envelope has already validated it. + * Namespaced kinds and attachment slot identifiers are not asset URLs. */ function findRejectedUrl(root: unknown): ScanResult | null { let budget = MAX_SCAN_VALUES @@ -85,6 +84,15 @@ function findRejectedUrl(root: unknown): ScanResult | null { if (value && typeof value === 'object') { for (const [key, child] of Object.entries(value)) { if (depth === 0 && key === 'type') continue + // Wall attachment slots contain a node ID, not a URL scheme. + if ( + depth === 0 && + key === 'slotId' && + typeof child === 'string' && + /^wall-point:[\w.-]+$/.test(child) + ) { + continue + } const hit = walk(child, [...path, key], depth + 1) if (hit) return hit } diff --git a/apps/editor/lib/scene-signature.test.ts b/apps/editor/lib/scene-signature.test.ts index 6bcce47df8..acecb07ba5 100644 --- a/apps/editor/lib/scene-signature.test.ts +++ b/apps/editor/lib/scene-signature.test.ts @@ -1,5 +1,9 @@ import { expect, test } from 'bun:test' -import { type PersistedSceneGraph, sceneGraphSignature } from './scene-signature' +import { + isPendingSceneGraphEcho, + type PersistedSceneGraph, + sceneGraphSignature, +} from './scene-signature' const NODE_ID = 'level_a1b2c3d4e5f6g7h8' const MATERIAL_ID = 'mat_a1b2c3d4e5f6g7h8' @@ -39,3 +43,13 @@ test('changing any signed field changes the signature', () => { base, ) }) + +test('an in-flight save echo cannot replace tools when event metadata is omitted', () => { + const saved = graph({ installedPlugins: ['pascal:bath-space'], collections: { col_1: {} } }) + const pending = new Set([sceneGraphSignature(saved)]) + expect(isPendingSceneGraphEcho(graph(), pending)).toBe(true) + expect(isPendingSceneGraphEcho(graph({ installedPlugins: [] }), pending)).toBe(false) + expect(isPendingSceneGraphEcho(graph({ rootNodeIds: [] }), pending)).toBe(false) + pending.clear() + expect(isPendingSceneGraphEcho(saved, pending)).toBe(false) +}) diff --git a/apps/editor/lib/scene-signature.ts b/apps/editor/lib/scene-signature.ts index a5a4587dd0..956bd4ef0c 100644 --- a/apps/editor/lib/scene-signature.ts +++ b/apps/editor/lib/scene-signature.ts @@ -25,3 +25,25 @@ export function sceneGraphSignature(graph: PersistedSceneGraph): string { installedPlugins: graph.installedPlugins ?? [], }) } + +/** Match a pending save even when an event omits optional graph metadata. */ +export function isPendingSceneGraphEcho( + graph: PersistedSceneGraph, + pending: ReadonlySet, +): boolean { + if (pending.has(sceneGraphSignature(graph))) return true + for (const signature of pending) { + const saved = JSON.parse(signature) as PersistedSceneGraph + if ( + sceneGraphSignature({ + ...saved, + ...graph, + collections: graph.collections ?? saved.collections, + materials: graph.materials ?? saved.materials, + installedPlugins: graph.installedPlugins ?? saved.installedPlugins, + }) === signature + ) + return true + } + return false +} diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 741c8ec93c..ff5f1dcb2d 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -4,7 +4,6 @@ import type { NextConfig } from 'next' const appDirectory = path.dirname(fileURLToPath(import.meta.url)) const portableBuild = process.env.PASCAL_PORTABLE_BUILD === '1' - const nextConfig: NextConfig = { ...(portableBuild ? { output: 'standalone' as const, outputFileTracingRoot: path.join(appDirectory, '../..') } @@ -44,6 +43,8 @@ const nextConfig: NextConfig = { '@pascal-app/editor', '@pascal-app/mcp', '@pascal-app/plugin-pool', + '@pascal-app/plugin-bath-space', + '@pascal-app/plugin-landscape', '@pascal-app/plugin-streetscape', '@pascal-app/plugin-trees', '@mint/pascal-plugin', @@ -53,11 +54,22 @@ const nextConfig: NextConfig = { '@dgreenheck/ez-tree', ], turbopack: { + root: path.resolve(appDirectory, '../..'), resolveAlias: { - react: './node_modules/react', - three: './node_modules/three', - '@react-three/fiber': './node_modules/@react-three/fiber', - '@react-three/drei': './node_modules/@react-three/drei', + '@pascal-app/core': './../../node_modules/@pascal-app/core', + '@pascal-app/editor': './../../node_modules/@pascal-app/editor', + '@pascal-app/viewer': './../../node_modules/@pascal-app/viewer', + '@pascal-app/nodes': './../../node_modules/@pascal-app/nodes', + react: './../../node_modules/react', + 'react-dom': './../../node_modules/react-dom', + three: './../../node_modules/three', + '@react-three/fiber': './../../node_modules/@react-three/fiber', + '@react-three/drei': './../../node_modules/@react-three/drei', + '@react-three/xr': './../../node_modules/@react-three/xr', + zustand: './../../node_modules/zustand', + zod: './../../node_modules/zod', + iwer: './../../node_modules/iwer', + '@iwer/devui': './../../node_modules/@iwer/devui', }, }, experimental: { diff --git a/apps/editor/package.json b/apps/editor/package.json index b7c683db11..18df3eee25 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,8 +21,10 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#483a080558293cd344170e66318230e2ac9b6ae8", - "@pascal-app/plugin-streetscape": "github:wass08/streetscape-pascal-plugin#1d9fe5e442fb7c0048066f3450ec54ccd8dd1f0f", + "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#357338ddb7e042498fca7b6e6ee2054edbeb70b7", + "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#d60ee247334dad037e02a432a50bed9264193402", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#1da7fb0d29691f3e2e488e24b436261266c692ce", + "@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.4.1", @@ -30,7 +32,7 @@ "@react-three/fiber": "^9.8.1", "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", - "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#31063230d0cd5b244b04eca12c6e31b5b757cb5e", + "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#9cd90457fc823d5d633d38e9575856c854aeb4d4", "clsx": "^2.1.1", "geist": "^1.7.2", "lucide-react": "^1.57.0", diff --git a/bun.lock b/bun.lock index 940e23b9ab..5992ce5dc9 100644 --- a/bun.lock +++ b/bun.lock @@ -36,10 +36,12 @@ "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", + "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#357338ddb7e042498fca7b6e6ee2054edbeb70b7", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#483a080558293cd344170e66318230e2ac9b6ae8", - "@pascal-app/plugin-streetscape": "github:wass08/streetscape-pascal-plugin#1d9fe5e442fb7c0048066f3450ec54ccd8dd1f0f", + "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#d60ee247334dad037e02a432a50bed9264193402", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#1da7fb0d29691f3e2e488e24b436261266c692ce", + "@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.4.1", @@ -47,7 +49,7 @@ "@react-three/fiber": "^9.8.1", "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", - "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#31063230d0cd5b244b04eca12c6e31b5b757cb5e", + "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#9cd90457fc823d5d633d38e9575856c854aeb4d4", "clsx": "^2.1.1", "geist": "^1.7.2", "lucide-react": "^1.57.0", @@ -608,6 +610,24 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="], + + "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], + + "@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.3.0", "", {}, "sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA=="], + + "@mapbox/unitbezier": ["@mapbox/unitbezier@1.0.0", "", {}, "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ=="], + + "@mapbox/vector-tile": ["@mapbox/vector-tile@3.0.0", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.0.0" } }, "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q=="], + + "@maplibre/geojson-vt": ["@maplibre/geojson-vt@6.2.0", "", { "dependencies": { "kdbush": "^4.1.0" } }, "sha512-r24Cj+32JFQG4tscFoXpVreNJRqMmP2PxDNRaU89SyDJKKC3SWTEuEUgf76JhGdR7uRv6LAr7bcOE4GWWUfmGw=="], + + "@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@26.4.4", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.3", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" }, "bin": { "gl-style-format": "dist/gl-style-format.mjs", "gl-style-migrate": "dist/gl-style-migrate.mjs", "gl-style-validate": "dist/gl-style-validate.mjs" } }, "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA=="], + + "@maplibre/mlt": ["@maplibre/mlt@1.3.0", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0" } }, "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ=="], + + "@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.3.2", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.1.0" } }, "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw=="], + "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546", "sha512-/itUH9r9OIP8ZPrklW8iWe6B2SDOtlL1m8r9hlVM4Rw5josYtDdkDKQ37FbanvxxuUKcQnukHbtxWe3svRaCrQ=="], @@ -812,13 +832,17 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], + "@pascal-app/plugin-bath-space": ["@pascal-app/plugin-bath-space@github:sudhir9297/pascal-cluster#357338d", { "dependencies": { "@tanstack/react-virtual": "^3.14.13" }, "peerDependencies": { "@pascal-app/core": "^1.0.1", "@pascal-app/editor": "^1.0.1", "@pascal-app/viewer": "^1.0.1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": ">=4.5.4 <4.6" } }, "sudhir9297-pascal-cluster-357338d", "sha512-PR6goK+qa4pFOBFFFUDrJxXs63rUSvNFnyJEQPq/fzOalLTzCIyGdaV7H/2bSA2xPMf8Ln3gFBdH4JtIKDocng=="], + "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "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" } }, "pascalorg-plugin-bones-5679260", "sha512-uQkyHHOl/VuYx2+d/MmcJui/KEAQZ2UUnO4ywp1XaopmD2YfN+Yx/fR/lS/VL2joEoyyZeBLC3KaXK1Sx5qEvg=="], "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#1aa0bc3", { "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.186", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-1aa0bc3", "sha512-wvCYDEr62ItSwgWf8zOJL0z97zTyZ7TszrFwLOcIhX7LRYrmHhP/6LE7lB6lBBeXNPExg7jRk8eQZm+ri6GfaQ=="], - "@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-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#d60ee24", { "dependencies": { "@types/clipper-lib": "^6.4.0", "clipper-lib": "^6.4.2", "polygon-clipping": "^0.15.7" }, "peerDependencies": { "@pascal-app/core": "^1.0.4", "@pascal-app/editor": "^1.0.4", "@pascal-app/viewer": "^1.0.4", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186.0", "zod": "^4.6.7" } }, "sudhir9297-pascal-cluster-d60ee24", "sha512-yKy3zcFmehZ6PzNzOvjWsc4gUKC+Hjo/Zwn58uIHrFAuYmLlp58sywzCJp56i+gt+NxKrtQgTpodDvSt8qsaEQ=="], + + "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#1da7fb0", { "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", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-1da7fb0", "sha512-aYpRIiOviz+HlYuf115smIefCz+pVCDTatf6/SxUevBPkoIEN48uwF2VjOEgwx8ZzjDM5G0dTKsQ3tRDHMnH4g=="], - "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:wass08/streetscape-pascal-plugin#1d9fe5e", { "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" } }, "wass08-streetscape-pascal-plugin-1d9fe5e", "sha512-nohMdi+yTkbXLZpBgyT+X5P7+boSaKLE3rvEWCBLmHhmV0ybfKruKxq3AV0131an4S7MWqjbJkESVGRqMSJp+Q=="], + "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:pascalorg/plugin-streetscape#bb563f3", { "dependencies": { "lucide-react": "^1.41.0", "maplibre-gl": "^6.7.0" }, "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", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-streetscape-bb563f3", "sha512-QA8Z7PC2/GwyoabHCqVM1hdr7ZvrChsK7cklKG+L1ZqNln0O3rn6ooEqJxbBarbyCM82+Kbb/qz6KerjDtl3cw=="], "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "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" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="], @@ -978,6 +1002,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.20", "", { "dependencies": { "@tanstack/virtual-core": "3.19.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9hbhFlc0lznjXmWYDB/KEl7PJxEPz0Qnj2FzJFse98miGvXtx7jzjE5jlasmtczmZ6TxXsyI4v7PuyGQm2q1nQ=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.19.5", "", {}, "sha512-X/wq6DtSK/XClznMM+I4JE63yyIwmyPk4Ts83bD0VKaIbwHNdRsO5QipCPX+3OovqYyr0puQNpbmp8+SPhspNg=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.11.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-u+6s4C2hskRYx6MkW2uT7BCW6sESCL/+5bjHDDWUfQfQliCSJ7lAHAL6RQaw0an9WU6nJvwYgT786mEWul2kDw=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.11.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-y3qpPbclA4MuzAf/pSp6ivifp2mBCiLMHyf/bVq2eAJLjTnzev9JG5eqFhM0UvDGi0b3QKRNZi2HiavPnLbEbw=="], @@ -998,12 +1026,16 @@ "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], + "@types/clipper-lib": ["@types/clipper-lib@6.4.0", "", {}, "sha512-y8WVQWLCIJobtcxk8SJj1f7OGVbQEL7RmfGtrWDkAKFxq2U6F0/7/hLYvDWlUnBayvnRYkq+QVZaUzm13mu+Ng=="], + "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/howler": ["@types/howler@2.2.13", "", {}, "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -1118,7 +1150,7 @@ "@visual-json/react": ["@visual-json/react@0.4.0", "", { "dependencies": { "@visual-json/core": "0.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-qiDrXI7wu9VoTE0TaLoJp7kwRj8SQTpkS/wkts5sBPA8078zdrIKc+0u0Y8wAXGYd3udw9dNfD4kmpO48OEwXQ=="], - "@webxr/plugin": ["@webxr/plugin@github:sudhir9297/webxr-pascal-plugin#3106323", { "dependencies": { "@iwer/devui": "2.3.0", "iwer": "2.3.0", "lucide-react": "^1.7.0" }, "peerDependencies": { "@pascal-app/core": "^1.0.0-beta.5", "@pascal-app/editor": "^1.0.0-beta.5", "@pascal-app/viewer": "^1.0.0-beta.5", "@react-three/drei": "^10", "@react-three/fiber": "^9", "@react-three/xr": "^6", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185 || ^0.186", "zustand": "^5" } }, "sudhir9297-webxr-pascal-plugin-3106323", "sha512-Kyfu6u8SeFtddqLcYy8iPXxQ+L2T6qIzhyDYCcy7kehhOf+qX//I7NLRVsO7dQYY+fEbErVbswj0pzdO8nYUFw=="], + "@webxr/plugin": ["@webxr/plugin@github:sudhir9297/webxr-pascal-plugin#9cd9045", { "dependencies": { "@iwer/devui": "2.3.0", "iwer": "2.3.0", "lucide-react": "^1.7.0" }, "peerDependencies": { "@pascal-app/core": "^1.0.0-beta.5", "@pascal-app/editor": "^1.0.0-beta.5", "@pascal-app/viewer": "^1.0.0-beta.5", "@react-three/drei": "^10", "@react-three/fiber": "^9", "@react-three/xr": "^6", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185 || ^0.186", "zustand": "^5" } }, "sudhir9297-webxr-pascal-plugin-9cd9045", "sha512-oICXf9DgdzLR+dVdAzp8z7knF+fZUUX2MJKZ/G2ljT5dXg6gmjPH16HWRy/Reb/hKILvcDGh5lw8RYQj5OBAuA=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -1232,6 +1264,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clipper-lib": ["clipper-lib@6.4.2", "", {}, "sha512-knglhjQX5ihNj/XCIs6zCHrTemdvHY3LPZP9XB2nq2/3igyYMFueFXtfp84baJvEE+f8pO1ZS4UVeEgmLnAprQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -1322,6 +1356,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "earcut": ["earcut@3.2.4", "", {}, "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg=="], + "editor": ["editor@workspace:apps/editor"], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -1658,12 +1694,16 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json-stringify-pretty-compact": ["json-stringify-pretty-compact@4.0.0", "", {}, "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + "kdbush": ["kdbush@4.1.0", "", {}, "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ=="], + "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], @@ -1724,6 +1764,8 @@ "manifold-3d": ["manifold-3d@3.5.4", "", { "dependencies": { "@gltf-transform/core": "^4.2.0", "@gltf-transform/extensions": "^4.2.0", "@gltf-transform/functions": "^4.2.0", "@jridgewell/resolve-uri": "^3.1.2", "@jridgewell/trace-mapping": "^0.3.31", "@jscadui/3mf-export": "^0.5.0", "commander": "^13.1.0", "convert-source-map": "^2.0.0", "fast-xml-parser": "^5.4.2", "fflate": "^0.8.0", "magic-string": "^0.30.21" }, "peerDependencies": { "esbuild-wasm": "^0.27.3" }, "bin": { "manifold-cad": "bin/manifold-cad" } }, "sha512-WWsme5HabwLHVcWPHT9m+s7mdmDVSEvCny+uXH1E7jEc8FbFUgM75C3Dh6NVw57Eeh3Ow7+xHCuXfxDMOemi9A=="], + "maplibre-gl": ["maplibre-gl@6.14.0", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.2.0", "@mapbox/unitbezier": "^1.0.0", "@mapbox/vector-tile": "^3.0.0", "@maplibre/geojson-vt": "^6.1.2", "@maplibre/maplibre-gl-style-spec": "^26.4.4", "@maplibre/mlt": "^1.3.0", "@maplibre/vt-pbf": "^4.3.2", "@types/geojson": "^7946.0.16", "bidi-js": "^1.1.0", "earcut": "^3.2.4", "gl-matrix": "^3.4.4", "kdbush": "^4.1.0", "murmurhash-js": "^1.0.0", "pbf": "^5.1.2", "potpack": "^2.1.0", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" } }, "sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -1768,6 +1810,8 @@ "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "murmurhash-js": ["murmurhash-js@1.0.0", "", {}, "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="], + "nanoid": ["nanoid@6.0.2", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-hIvVOb9Qr1p/9CNNLOe0OlWvgx/ziTsVwKN/GDSsuArN2K0wDiafP3s0SWmsm7o2BdnyS8Z9Z64NFBUbn6ziBg=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -1856,6 +1900,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="], + "pdfkit": ["pdfkit@0.20.2", "", { "dependencies": { "@noble/ciphers": "^1.3.0", "@noble/hashes": "^1.8.0", "fflate": "^0.8.3", "fontkit": "^2.0.4", "linebreak": "^1.1.0", "png-js": "^2.0.0" } }, "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1892,6 +1938,8 @@ "property-graph": ["property-graph@4.1.0", "", {}, "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="], + "protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1904,6 +1952,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1942,6 +1992,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="], + "resolve.exports": ["resolve.exports@2.0.4", "", {}, "sha512-zepuGL0ulf+nt+XaFrlKeaiTJeyPFPLLY62+ncq1/VTHBdt3HsfXOH60priZ6tnXQozdGcDeIF5JVTanvIcYWQ=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -2072,6 +2124,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -2342,6 +2396,10 @@ "manifold-3d/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + "maplibre-gl/bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + + "maplibre-gl/potpack": ["potpack@2.1.0", "", {}, "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "ndarray-pixels/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], diff --git a/package.json b/package.json index 8bc30be160..3e03d6f11b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "check-types": "turbo run check-types", "test": "turbo run test", "ci": "bun run check && bun run skills:validate && bun run check-types && bun run test && bun run build", - "postinstall": "[ -n \"$CI\" ] || [ ! -e .git ] || git config core.hooksPath .githooks", + "postinstall": "([ -n \"$CI\" ] || [ ! -e .git ] || git config core.hooksPath .githooks)", "skills:validate": "bun scripts/validate-skills.ts && bun test scripts/cursor-plugin-policy.test.ts scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts", "kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", "clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache", diff --git a/packages/cli/README.md b/packages/cli/README.md index 5bd4cfa358..973f553806 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -84,10 +84,18 @@ npx @pascal-app/cli editor --foreground --no-open ## The web editor runtime -The npm package carries the CLI and the MCP service only: about 0.5 MB compressed and -2.5 MB installed. The web editor itself—the Next.js server, its static assets, and the -bundled item library—is published as one archive per CLI version, about 64 MB compressed -and 106 MB on disk. +The npm package carries the CLI and the MCP service only: about 1.7 MiB compressed and +8.5 MiB installed. The web editor itself—the Next.js server, its static assets, and the +bundled item and plugin libraries—is published as one archive per CLI version. +With Bath Space, Landscape, Pool, Streetscape and WebXR included, the current archive +is about 126 MiB compressed, down from 184 MiB. Bath Space catalog thumbnails fit +within 512 × 512 pixels; all catalog entries, offline models and material textures +remain included. The runtime is downloaded once per version, outside the npm package. + +`stage-runtime` reports the compressed size and the static-media breakdown. The packed +runtime smoke check enforces a 140 MiB release ceiling and verifies every bundled +WebP image and GLB model through the installed editor's HTTP server. A package revision +update should include this size report so media growth is visible in review. Every command that starts the editor (`editor`, `start`, `open`, `resume`, `projects`, `project open`, `update`) resolves that runtime in this order: diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts index 9db2d6db73..1bdd8af523 100644 --- a/packages/cli/scripts/smoke-packed-runtime.ts +++ b/packages/cli/scripts/smoke-packed-runtime.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' -import { copyFile, mkdtemp, open, readFile, rm, stat } from 'node:fs/promises' +import { copyFile, mkdtemp, open, readdir, readFile, rm, stat } from 'node:fs/promises' import http from 'node:http' import os from 'node:os' import path from 'node:path' @@ -179,6 +179,7 @@ async function checkEditorFromLocalArchive(executable: string, archiveFile: stri if (!rootResponse.ok) throw new Error(`editor root returned ${rootResponse.status}`) const scenesResponse = await fetch(`${started.url}/scenes`) if (!scenesResponse.ok) throw new Error(`editor scenes returned ${scenesResponse.status}`) + await checkOfflineCatalogMedia(`http://127.0.0.1:${started.port}`) const repeatedStart = JSON.parse( ( await run( @@ -255,6 +256,37 @@ async function checkEditorFromLocalArchive(executable: string, archiveFile: stri await run(process.execPath, [executable, 'stop', '--json'], undefined, smokeEnvironment) } +async function checkOfflineCatalogMedia(origin: string): Promise { + const directory = path.join(packageDirectory, 'build/runtime/apps/editor/.next/static/media') + const files = (await readdir(directory)).filter((file) => /\.(webp|glb)$/.test(file)) + if ( + !files.some((file) => file.endsWith('.webp')) || + !files.some((file) => file.endsWith('.glb')) + ) { + throw new Error('the staged runtime is missing offline catalog images or models') + } + for (let start = 0; start < files.length; start += 8) { + await Promise.all( + files.slice(start, start + 8).map(async (file) => { + const response = await fetch(`${origin}/_next/static/media/${encodeURIComponent(file)}`, { + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw new Error(`offline catalog asset ${file}: HTTP ${response.status}`) + const [served, staged] = await Promise.all([ + response.arrayBuffer(), + readFile(path.join(directory, file)), + ]) + if (!Buffer.from(served).equals(staged)) { + throw new Error(`the installed archive changed offline catalog asset ${file}`) + } + }), + ) + } + console.log( + `Verified ${files.length} offline catalog images and models from the installed archive.`, + ) +} + async function verifyStagedWebRuntime(): Promise<{ file: string; size: number; url: string }> { const source = JSON.parse( await readFile(path.join(packageDirectory, 'dist/runtime-source.json'), 'utf8'), @@ -277,7 +309,8 @@ async function verifyStagedWebRuntime(): Promise<{ file: string; size: number; u if (size !== source.size) { throw new Error(`${archiveName} is ${size} bytes; runtime-source.json records ${source.size}`) } - const maximumArchiveSize = 70 * 1024 * 1024 + // Catalog thumbnails are sized for the sidebar; retain bounded room for new assets. + const maximumArchiveSize = 140 * 1024 * 1024 if (size > maximumArchiveSize) { throw new Error( `the web runtime archive exceeds its release budget: ${formatMb(size)} MB > ${formatMb(maximumArchiveSize)} MB`, diff --git a/packages/cli/scripts/stage-runtime.ts b/packages/cli/scripts/stage-runtime.ts index 53a85cc643..525e914512 100644 --- a/packages/cli/scripts/stage-runtime.ts +++ b/packages/cli/scripts/stage-runtime.ts @@ -94,6 +94,7 @@ await flattenBunNodeModules(outputDirectory) await materializeSymlinks(outputDirectory) await rm(path.join(outputDirectory, 'node_modules/.bun'), { recursive: true, force: true }) await pruneBuildOnlyFiles(outputDirectory) +await reportStaticMedia(path.join(outputDirectory, 'apps/editor/.next/static/media')) const nativeFiles = await findNativeModules(outputDirectory) if (nativeFiles.length > 0) { throw new Error(`portable runtime contains native modules:\n${nativeFiles.join('\n')}`) @@ -348,3 +349,20 @@ async function findNativeModules(root: string): Promise { await walk(root) return result.sort() } + +async function reportStaticMedia(directory: string): Promise { + const totals = new Map() + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isFile()) continue + const extension = path.extname(entry.name) || '(no extension)' + const total = totals.get(extension) ?? { bytes: 0, count: 0 } + total.bytes += (await stat(path.join(directory, entry.name))).size + total.count += 1 + totals.set(extension, total) + } + for (const [extension, total] of [...totals].sort((a, b) => b[1].bytes - a[1].bytes)) { + console.log( + `Runtime media ${extension}: ${total.count} files, ${formatMegabytes(total.bytes)} MB`, + ) + } +} diff --git a/packages/cli/src/editor-process.ts b/packages/cli/src/editor-process.ts index 6e77743b82..e6094b6fb7 100644 --- a/packages/cli/src/editor-process.ts +++ b/packages/cli/src/editor-process.ts @@ -141,7 +141,10 @@ export async function startEditor(options: StartEditorOptions): Promise startEditorUnlocked(options)) } -async function startEditorUnlocked(options: StartEditorOptions): Promise { +async function startEditorUnlocked( + options: StartEditorOptions, + selectedRuntime?: ActiveRuntime, +): Promise { await ensurePascalDirectories(options.paths) options.onProgress?.({ step: 'storage-ready', dataDirectory: options.paths.data }) let currentStatus: EditorStatus @@ -153,7 +156,8 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise { const started = await startEditor({ paths, runtimeSource: firstSource }) const candidate = await installBundledRuntime(paths, secondSource, { activate: false }) - const result = await activateEditorRuntime(paths, candidate) + // Exercise the published layout, where a CLI pin exists even for an explicit activation. + const cliDist = path.join(root, 'cli/dist') + const built = await Bun.build({ + entrypoints: [fileURLToPath(new URL('./editor-process.ts', import.meta.url))], + outdir: cliDist, + target: 'node', + }) + expect(built.success).toBe(true) + await writeFile(path.join(cliDist, '../package.json'), JSON.stringify({ version: '3.0.0' })) + await writeFile( + path.join(cliDist, 'runtime-source.json'), + JSON.stringify({ + version: '3.0.0', + url: 'https://127.0.0.1:1/runtime.tar.gz', + sha256: 'a'.repeat(64), + size: 10, + }), + ) + const packaged = await import(pathToFileURL(path.join(cliDist, 'editor-process.js')).href) + const result = await packaged.activateEditorRuntime(paths, candidate) expect(result.restarted).toBe(true) const status = await getEditorStatus(paths) diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index d603397ddf..60f8c18879 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -226,18 +226,26 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [ remaps: [...CLONES, 'delete-nodes'], note: "'ground' pins the node to the level base; deleting the slab strips the field.", }), + row({ + kind: 'stair', + path: 'deckSlabId', + ...policy('node', 'host', 'drop', 'strip'), + targetKinds: ['slab'], + remaps: [...CLONES, 'delete-nodes'], + }), row({ kind: 'fence', path: 'supportSurfaceNodeId', ...policy('node', 'host', 'drop', 'strip'), - remaps: ['clone-scene-graph'], + remaps: [...CLONES, 'clone-nodes-into', 'delete-nodes'], + note: 'A registered walking surface supporting a railing.', }), row({ kind: 'stair', - path: 'deckSlabId', + path: 'destinationSurfaceId', ...policy('node', 'host', 'drop', 'strip'), - targetKinds: ['slab'], - remaps: [...CLONES, 'delete-nodes'], + remaps: [...CLONES, 'clone-nodes-into', 'delete-nodes'], + note: 'A registered top surface supporting a stair connection.', }), row({ kind: 'roof', @@ -910,6 +918,7 @@ const described = (reason: string, paths: readonly string[]) => /** Metadata keys found in editor sources that are not references. */ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[] = [ + { path: 'metadata.roomBoundary', reason: 'Boolean opt-out from room boundary detection.' }, ...described('Flag, enum, number or tag; names nothing.', [ 'annotationObstacle', 'annotationRole', diff --git a/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts index 9ad31a19c0..088a61c3ea 100644 --- a/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts +++ b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from 'bun:test' +import { FenceNode, resolveFenceConstructionSupport } from '@pascal-app/core' import type { AnyNode, SlabNode } from '../../schema' import useScene from '../../store/use-scene' import { spatialGridManager } from './spatial-grid-manager' @@ -178,3 +179,14 @@ describe('resolveFenceSupportSlabPatch', () => { }) }) }) + +test('explicit railing hosts must be top surfaces on the fence level', () => { + const deck = makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION) + const fence = FenceNode.parse({ parentId: LEVEL_ID, start: [0, 0], end: [3, 0] }) + for (const host of [{ ...deck, parentId: 'level_other' }, makeLevel()]) { + const result = resolveFenceConstructionSupport(fence, LEVEL_ID, nodesFor(makeLevel(), host), { + supportSurfaceNodeId: host.id, + }) + expect(result.supportSurfaceNodeId).toBeUndefined() + } +}) diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts index c77effaf6b..883ebcc53b 100644 --- a/packages/core/src/hooks/spatial-grid/support-host-patch.ts +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -379,13 +379,26 @@ export function resolveFenceConstructionSupport( nodes: Record, options?: FenceConstructionOptions, ): FenceNode { - if (options?.supportSurfaceNodeId && nodes[options.supportSurfaceNodeId]) { + const requestedSurface = options?.supportSurfaceNodeId + ? nodes[options.supportSurfaceNodeId] + : null + if ( + requestedSurface?.parentId === levelId && + nodeRegistry.get(requestedSurface.type)?.capabilities?.surfaces?.top + ) { return { ...fence, supportSlabId: undefined, - supportSurfaceNodeId: options.supportSurfaceNodeId, + supportSurfaceNodeId: requestedSurface.id, } } + const surface = fence.supportSurfaceNodeId ? nodes[fence.supportSurfaceNodeId] : null + if ( + surface?.parentId === levelId && + nodeRegistry.get(surface.type)?.capabilities?.surfaces?.top + ) { + return { ...fence, supportSlabId: undefined } + } const supportPatch = resolveFenceSupportSlabPatch({ ...fence, parentId: levelId }, nodes, { maxElevation: options?.supportCap ?? null, preferredSlabId: options?.preferredSupportSlabId ?? null, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a06a2906e..c7fc1391e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -573,6 +573,7 @@ export * from './registry' // and must stay free of React imports. export { useRegistryVersion } from './registry/use-registry-version' export * from './schema' +export * from './schema/nodes/stair-defaults' export * from './services' export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export * from './services/project-file-sink' diff --git a/packages/core/src/lib/room-graph.ts b/packages/core/src/lib/room-graph.ts index 2b7a2dbd7a..bd4d82142d 100644 --- a/packages/core/src/lib/room-graph.ts +++ b/packages/core/src/lib/room-graph.ts @@ -988,6 +988,7 @@ export function extractRooms( boundaries: BoundaryNode[], options: { includeHoles?: boolean } = {}, ): ExtractedRoom[] { + boundaries = boundaries.filter((boundary) => boundary.metadata?.roomBoundary !== false) const joined = extractRoomGraph(boundaries, options, true) if (!joined.bodyJoints) return joined.rooms const plain = extractRoomGraph(boundaries, options, false).rooms diff --git a/packages/core/src/lib/room-topology-index.ts b/packages/core/src/lib/room-topology-index.ts index 5e7638ead8..a3a8960061 100644 --- a/packages/core/src/lib/room-topology-index.ts +++ b/packages/core/src/lib/room-topology-index.ts @@ -255,7 +255,7 @@ export class RoomTopologyIndex { private isBoundary(node: any): node is BoundaryNode { return ( - node?.type === 'wall' || + (node?.type === 'wall' && node.metadata?.roomBoundary !== false) || (this.options.includeSeparators !== false && node?.type === 'separator') ) } diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index f81d621e6d..1570d3f582 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -2184,6 +2184,18 @@ describe('joints never cost a room the plain rules found', () => { }) describe('wallClosesRoom', () => { + test('retaining walls do not enclose rooms, even in a closed loop', () => { + const walls = squareWalls().map((wall) => + WallNode.parse({ + ...wall, + metadata: { ...wall.metadata, landscapeRetainingWall: true, roomBoundary: false }, + }), + ) + expect(detectSpacesForLevel('level-1', walls).rooms).toHaveLength(0) + expect(wallClosesRoom(walls, walls[3]!)).toBe(false) + expect(detectSpacesForLevel('level-1', squareWalls()).rooms).toHaveLength(1) + }) + test('is false while a chain is still open, true once it encloses a room', () => { const open = [ WallNode.parse({ start: [0, 0], end: [4, 0] }), diff --git a/packages/core/src/lib/structure-kernel.ts b/packages/core/src/lib/structure-kernel.ts index 81c1b12b3b..fc2c96324f 100644 --- a/packages/core/src/lib/structure-kernel.ts +++ b/packages/core/src/lib/structure-kernel.ts @@ -172,6 +172,7 @@ export function classifyWallSides( spans: readonly BoundarySpan[], outdoor: ReadonlySet = new Set(), ): Pick { + if (wall.metadata?.roomBoundary === false) return { frontSide: 'unknown', backSide: 'unknown' } const boundary = spans.filter((span) => span.boundaryId === wall.id) if (!boundary.length) return { frontSide: wall.frontSide, backSide: wall.backSide } const indoor = (face: BoundarySpan['face']) => diff --git a/packages/core/src/lib/surface-support-references.ts b/packages/core/src/lib/surface-support-references.ts new file mode 100644 index 0000000000..3cd3ec53fb --- /dev/null +++ b/packages/core/src/lib/surface-support-references.ts @@ -0,0 +1,27 @@ +import type { AnyNode } from '../schema/types' + +const fields = ['supportSurfaceNodeId', 'destinationSurfaceId'] as const + +export function remapSurfaceSupportReferences( + node: AnyNode, + ids: ReadonlyMap, +): void { + const record = node as unknown as Record + for (const field of fields) { + const id = record[field] + if (typeof id === 'string') record[field] = ids.get(id) ?? id + } +} + +export function deletedSurfaceSupportPatch( + node: AnyNode, + deleted: ReadonlySet, +): Partial | null { + const record = node as unknown as Record + const patch: Record = {} + for (const field of fields) { + const id = record[field] + if (typeof id === 'string' && deleted.has(id)) patch[field] = undefined + } + return Object.keys(patch).length ? (patch as Partial) : null +} diff --git a/packages/core/src/registry/scene-api.ts b/packages/core/src/registry/scene-api.ts index 4257c971a4..6ad0a40892 100644 --- a/packages/core/src/registry/scene-api.ts +++ b/packages/core/src/registry/scene-api.ts @@ -22,6 +22,7 @@ import type { SceneApi } from './types' export type SceneStoreLike = { getState: () => { nodes: Record + installedPlugins?: string[] rootNodeIds: AnyNodeId[] dirtyNodes: Set createNode: (node: AnyNode, parentId?: AnyNodeId) => void @@ -98,6 +99,10 @@ export function createSceneApi(store: SceneStoreLike): SceneApi { return store.getState().nodes }, + installedPlugins() { + return store.getState().installedPlugins + }, + update(id, patch) { const [update] = filterDerivedNodeWrites(store.getState().nodes, { update: [{ id, data: patch }], diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts index a6401017ff..4f8b1bb632 100644 --- a/packages/core/src/registry/subtree.ts +++ b/packages/core/src/registry/subtree.ts @@ -3,6 +3,7 @@ import { remapConstructionDimensionReferences, remapMeasurementReferences, } from '../lib/measurement-geometry' +import { remapSurfaceSupportReferences } from '../lib/surface-support-references' import { generateId } from '../schema/base' import type { AnyNode, AnyNodeId } from '../schema/types' @@ -170,6 +171,7 @@ export function cloneNodesInto( .filter((cid): cid is AnyNodeId => cid !== undefined) } + remapSurfaceSupportReferences(cloned, idMap) if (cloned.type === 'procedural-item') { cloned.attachments = Object.fromEntries( Object.entries(cloned.attachments).flatMap(([id, surface]) => { diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 5b43f29d03..b96c39e270 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -583,6 +583,8 @@ export type FloorplanGeometry = * affordances (rotation, scale) without expanding the union. */ variant?: 'endpoint' | 'curve' + /** Keep the drag target the same size in pixels when zooming out. */ + screenSized?: boolean affordance: string payload: unknown } @@ -982,6 +984,9 @@ export type FloorplanMoveTarget = (args: { * * Extensions surface only when the contributing plugin is installed in * the project (same `installedPlugins` gate as panels and node kinds). + * An extension with `primaryWhen` replaces the regular controls for + * matching nodes on desktop and mobile. Its component supplies its own + * `PanelSection` groups. */ export type InspectorExtension = { /** Globally unique id, e.g. `pascal:bones:wall-engineering`. */ @@ -996,6 +1001,8 @@ export type InspectorExtension = { title: string /** Lazy section body; receives `{ node }` (the selected node). */ component: LazyComponent + /** When true for the selected node, this extension owns the inspector body. */ + primaryWhen?: (node: AnyNode) => boolean } export type Plugin = { @@ -1097,6 +1104,11 @@ export type NodeDefinition> = { * Has no effect when `distributionRole` is not `'fitting'`. */ portConnectivityFollow?: boolean + connectedMove?: ( + node: z.infer, + preview: z.infer, + nodes: Readonly>, + ) => { id: AnyNodeId; data: Partial }[] defaults: () => Omit, 'id' | 'type'> @@ -2196,6 +2208,8 @@ export type CutsContext = { nodes: Readonly> } export type MovableConfig = { axes: ReadonlyArray<'x' | 'y' | 'z'> gridSnap?: boolean + /** Keep an existing floor-placed node on its current level and visual elevation while dragging. */ + preserveLevelAndElevation?: boolean /** Allow an ordinary primary-button body drag to enter the move tool. */ directDrag?: boolean /** @@ -2265,6 +2279,8 @@ export type MovableConfig = { } export type MovableParentFrame = { + /** Keep selection and manipulation on the child instead of promoting its parent. */ + independent?: boolean /** The parent node owning the local frame; `null` → move in plan frame. */ resolveParent: (node: AnyNode, nodes: Readonly>) => AnyNode | null /** Parent's Y rotation, composed onto the child's preview rotation. */ @@ -2404,7 +2420,13 @@ export type SnapPointKind = 'start' | 'end' | 'midpoint' | 'center' | 'corners' export type SurfacesConfig = { hosting?: SurfaceProvider | false top?: { - height: number | ((n: AnyNode, context: { nodes: Record }) => number) + /** Height above the node origin; point, when supplied, is in the parent XZ frame. */ + height: + | number + | (( + n: AnyNode, + context: { nodes: Readonly>; point?: readonly [number, number] }, + ) => number) /** Resolve support from node data; null means this point is outside the support footprint. */ supportHeight?: ( node: AnyNode, @@ -2412,6 +2434,8 @@ export type SurfacesConfig = { z: number, context: { nodes: Readonly> }, ) => number | null + /** Closed walking-surface perimeter in the host's parent XZ frame; optional inward offset in metres. */ + boundary?: (n: AnyNode, inset?: number) => ReadonlyArray } sides?: { faces: 'all' | ReadonlyArray } custom?: SurfaceQuery @@ -2989,6 +3013,7 @@ export type SceneApi = { * do not mutate. */ nodes: () => Readonly> + installedPlugins?: () => readonly string[] | undefined update: (id: AnyNodeId, patch: Partial) => void upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId createMany?: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index f689470995..ef71e5a909 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -81,7 +81,8 @@ export const FenceNode = BaseNode.extend({ // Top surface selected when drawing on a shaped node. The sampled surface // is resolved again for both preview and committed geometry. supportSurfaceNodeId: z.string().optional(), - // Manual vertical offset from the elected slab, shaped surface, or level base. + // Manual vertical offset from the elected slab, shaped surface, or level + // support. Moves the complete fence body without changing its height. supportOffset: z.number().finite().optional(), surfaceMode: FenceSurfaceMode.default('auto'), transitionMode: FenceTransitionMode.default('slope'), diff --git a/packages/editor/src/components/tools/stair/stair-defaults.ts b/packages/core/src/schema/nodes/stair-defaults.ts similarity index 100% rename from packages/editor/src/components/tools/stair/stair-defaults.ts rename to packages/core/src/schema/nodes/stair-defaults.ts diff --git a/packages/core/src/schema/nodes/stair.test.ts b/packages/core/src/schema/nodes/stair.test.ts new file mode 100644 index 0000000000..beec38bf9f --- /dev/null +++ b/packages/core/src/schema/nodes/stair.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, StairNode, useScene } from '@pascal-app/core' + +describe('StairNode surface connection', () => { + test('defaults magnetic surface snapping on and preserves its surface link', () => { + const legacy = StairNode.parse({ id: 'stair_legacy', type: 'stair' }) + expect(legacy.autoSurfaceSnap).toBeUndefined() + expect(legacy.destinationSurfaceId).toBeUndefined() + + const attached = StairNode.parse({ + ...legacy, + autoSurfaceSnap: false, + destinationSurfaceId: 'patio_1', + }) + expect(attached.autoSurfaceSnap).toBe(false) + expect(attached.destinationSurfaceId).toBe('patio_1') + expect(StairNode.parse(JSON.parse(JSON.stringify(attached)))).toMatchObject({ + autoSurfaceSnap: false, + destinationSurfaceId: 'patio_1', + }) + }) +}) + +test('loading a scene preserves legacy stair destination and snap preference under generic names', () => { + const previous = useScene.getState() + const legacy = { + ...StairNode.parse({ id: 'stair_migration' }), + landscapeSurfaceId: 'deck_1', + autoLandscapeSnap: false, + } + try { + useScene.getState().setScene({ [legacy.id]: legacy as AnyNode }, [legacy.id]) + const loaded = useScene.getState().nodes[legacy.id] + expect(loaded).toMatchObject({ destinationSurfaceId: 'deck_1', autoSurfaceSnap: false }) + expect(loaded).not.toHaveProperty('landscapeSurfaceId') + expect(loaded).not.toHaveProperty('autoLandscapeSnap') + } finally { + useScene.setState(previous, true) + } +}) diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index 79e875a3f5..c8021c8a85 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -64,6 +64,9 @@ export const StairNode = BaseNode.extend({ designTargets: StairDesignTargets.optional(), uniformRisers: z.boolean().optional(), stepCount: z.number().default(10), + // Optional magnetic destination connection, independent of the source support. + autoSurfaceSnap: z.boolean().optional(), + destinationSurfaceId: z.string().optional(), thickness: z.number().default(0.25), fillToFloor: z.boolean().default(true), construction: StairConstruction.optional(), @@ -112,6 +115,7 @@ export const StairNode = BaseNode.extend({ - totalRise: total stair height - stepCount: number of visible steps - construction: optional explicit construction and finish details, inherited by child segments; absence preserves legacy bodies + - autoSurfaceSnap: magnetically connect straight flights to nearby registered top surfaces - thickness: stair slab / tread thickness - fillToFloor: whether the stair mass fills down to the floor or uses tread thickness only - innerRadius: inner curve radius for curved stairs diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index b647747b4e..f675bf17bc 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -142,6 +142,11 @@ export { snapVec3ToGrid, snapWorldXZToBuildingLocal, } from './snap' +export { + getBoundarySurfaces, + resolveStairSurfaceSnap, + type StairSurfaceSnap, +} from './stair-surface-snap' export { CEILING_CLAMP_MARGIN, CEILING_DRAW_OFFSET, diff --git a/packages/core/src/services/port-connectivity.test.ts b/packages/core/src/services/port-connectivity.test.ts index 7d9cc1ea5c..01110b1392 100644 --- a/packages/core/src/services/port-connectivity.test.ts +++ b/packages/core/src/services/port-connectivity.test.ts @@ -347,3 +347,61 @@ describe('port connectivity — joint follow (stretch vs translate)', () => { expect(connectivity.connections.find((c) => c.nodeId === distant.id)).toBeUndefined() }) }) + +test('kind-owned connectivity keeps fixed neighbors and snapshots custom move followers', () => { + registerNode({ + kind: 'test:connected-equipment', + schemaVersion: 1, + schema: {}, + category: 'utility', + defaults: () => ({}), + connectedMove: (_node: AnyNode, preview: AnyNode, nodes: Record) => { + const position = (preview as unknown as { position: Point }).position + const path = (nodes['custom_run'] as unknown as { path: Point[] }).path + return [{ id: 'custom_run' as AnyNodeId, data: { path: [position, path[1]] } }] + }, + } as unknown as AnyNodeDefinition) + const equipment = { + id: 'equipment', + type: 'test:connected-equipment', + position: [0, 0, 0], + } as unknown as AnyNode + const run = { + id: 'custom_run', + type: 'duct-segment', + path: [ + [0, 0, 0], + [5, 0, 0], + ], + } as unknown as AnyNode + const snapshot = analyzePortConnectivity(equipment, { [equipment.id]: equipment, [run.id]: run }) + expect(snapshot.connections).toEqual([ + { + kind: 'run', + nodeId: 'custom_run', + startPath: [ + [0, 0, 0], + [5, 0, 0], + ], + }, + ]) + const updated = resolveConnectivityUpdates(snapshot, { + ...equipment, + position: [2, 0, 3], + } as unknown as AnyNode) + expect(updated).toEqual([ + { + id: 'custom_run', + data: { + path: [ + [2, 0, 3], + [5, 0, 0], + ], + }, + }, + ]) + expect((run as unknown as { path: Point[] }).path).toEqual([ + [0, 0, 0], + [5, 0, 0], + ]) +}) diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts index 57c0b0d459..f44160e9c8 100644 --- a/packages/core/src/services/port-connectivity.ts +++ b/packages/core/src/services/port-connectivity.ts @@ -84,6 +84,7 @@ type GraphNode = { type Adjacency = Record>> export type PortConnectivity = { + customResolver?: (preview: AnyNode) => { id: AnyNodeId; data: Partial }[] movedNodeId: AnyNodeId /** The moved node's port world positions at edit-start, keyed by port id — * the reference each frame's delta is measured from. */ @@ -144,6 +145,39 @@ export function analyzePortConnectivity( movedNode: AnyNode, nodes: Record, ): PortConnectivity { + const custom = nodeRegistry.get(movedNode.type)?.connectedMove + if (custom) { + const resolve = (preview: AnyNode) => custom(movedNode, preview, nodes) + const initial = resolve(movedNode) + return { + movedNodeId: movedNode.id as AnyNodeId, + customResolver: resolve, + startMovedPorts: {}, + graph: {}, + adjacency: {}, + connections: initial.flatMap((update) => { + const path = (nodes[update.id] as unknown as { path?: Point[] })?.path + if (path) + return [ + { + kind: 'run' as const, + nodeId: update.id, + startPath: path.map((p) => [...p] as Point), + }, + ] as PortConnection[] + const position = (nodes[update.id] as unknown as { position?: Point })?.position + return position + ? [ + { + kind: 'rigid-node' as const, + nodeId: update.id, + startPosition: [...position] as Point, + }, + ] + : [] + }), + } + } const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M const movedPorts = portsOf(movedNode) ?? [] @@ -353,6 +387,7 @@ export function resolveConnectivityUpdates( connectivity: PortConnectivity, previewNode: AnyNode, ): { id: AnyNodeId; data: Partial }[] { + if (connectivity.customResolver) return connectivity.customResolver(previewNode) const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity if (Object.keys(graph).length === 0) return [] diff --git a/packages/core/src/services/stair-surface-snap.test.ts b/packages/core/src/services/stair-surface-snap.test.ts new file mode 100644 index 0000000000..3928d3337d --- /dev/null +++ b/packages/core/src/services/stair-surface-snap.test.ts @@ -0,0 +1,196 @@ +import { expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + nodeRegistry, + registerNode, + resolveStairSurfaceSnap, + StairNode, +} from '@pascal-app/core' +import { z } from 'zod' + +const stair = StairNode.parse({ parentId: 'level_1', position: [0, 0, -2], width: 1 }) +type FixtureSurface = { + position: number[] + rotation: number[] + width: number + depth: number + thickness: number + elevation?: number + shape: string +} +const surface = (type: string, extra: Record = {}) => { + if (!nodeRegistry.has(type)) + registerNode({ + kind: type, + schemaVersion: 1, + schema: z.object({ type: z.literal(type) }), + category: 'site', + defaults: () => ({}), + capabilities: { + surfaces: { + top: { + height: (raw: AnyNode) => { + const node = raw as unknown as FixtureSurface + return ( + node.thickness + + (type === 'landscape:patio' + ? (node.elevation ?? 0) + Math.min(0.045, node.thickness / 3) + : 0) + ) + }, + boundary: (raw: AnyNode) => { + const node = raw as unknown as FixtureSurface + const local: [number, number][] = + node.shape === 'circle' + ? Array.from({ length: 128 }, (_, i) => [ + (Math.cos((i * Math.PI * 2) / 128) * node.width) / 2, + (Math.sin((i * Math.PI * 2) / 128) * node.width) / 2, + ]) + : [ + [-node.width / 2, -node.depth / 2], + [node.width / 2, -node.depth / 2], + [node.width / 2, node.depth / 2], + [-node.width / 2, node.depth / 2], + ] + const c = Math.cos(node.rotation[1]!), + s = Math.sin(node.rotation[1]!) + return local.map(([x, z]): [number, number] => [ + node.position[0]! + x * c + z * s, + node.position[2]! - x * s + z * c, + ]) + }, + }, + }, + }, + } as AnyNodeDefinition) + return { + id: `${type}_1`, + type, + parentId: 'level_1', + position: [0, 0, 0], + rotation: [0, 0, 0], + width: 4, + depth: 3, + thickness: 0.35, + shape: 'rectangle', + ...extra, + } as unknown as AnyNode +} + +test('connects native stairs to a deck and sizes the flight', () => { + const deck = surface('landscape:deck') + const result = resolveStairSurfaceSnap(stair, { [deck.id]: deck }, [0, 0, -2], 3) + expect(result).not.toBeNull() + expect(result!.position[2] + result!.length).toBeCloseTo(-1.5) + expect(result!.rotation).toBeCloseTo(0) + expect(result!.totalRise).toBeCloseTo(0.35) + expect(result!.stepCount).toBe(2) + expect(result!.length).toBeGreaterThanOrEqual(0.56) +}) + +test('reads patio elevation and rotates toward a landing', () => { + const patio = surface('landscape:patio', { thickness: 0.12, elevation: 0.2 }) + expect( + resolveStairSurfaceSnap(stair, { [patio.id]: patio }, [0, 0, -2], 3)?.totalRise, + ).toBeCloseTo(0.36) + const landing = surface('landscape:landing', { rotation: [0, Math.PI / 2, 0] }) + const result = resolveStairSurfaceSnap(stair, { [landing.id]: landing }, [-2, 0, 0], 3) + expect(result?.rotation).toBeCloseTo(Math.PI / 2) + expect(result!.position[0] + result!.length).toBeCloseTo(-1.5) +}) + +test('does not pull stairs across the scene', () => { + const deck = surface('landscape:deck') + expect(resolveStairSurfaceSnap(stair, { [deck.id]: deck }, [20, 0, 20], 3)).toBeNull() +}) + +test('connects to a concrete slab and accepts a cursor on its edge', () => { + const slab = surface('landscape:concrete-slab', { thickness: 0.18 }) + const result = resolveStairSurfaceSnap(stair, { [slab.id]: slab }, [0, 0, -1.5], 3) + expect(result?.totalRise).toBeCloseTo(0.18) + expect(result!.position[2] + result!.length).toBeCloseTo(-1.5) +}) + +test('per-stair switch disables magnetic attachment', () => { + const deck = surface('landscape:deck') + const disabled = StairNode.parse({ ...stair, autoSurfaceSnap: false }) + expect(resolveStairSurfaceSnap(disabled, { [deck.id]: deck }, [0, 0, -2], 3)).toBeNull() + expect(StairNode.parse({}).autoSurfaceSnap).toBeUndefined() +}) + +test('reconnects a placed stair after its deck height changes', () => { + const deck = surface('landscape:deck', { thickness: 0.35 }) + const first = resolveStairSurfaceSnap(stair, { [deck.id]: deck }, [0, 0, -2], 3)! + const placed = StairNode.parse({ + ...stair, + position: first.position, + rotation: first.rotation, + destinationSurfaceId: deck.id, + totalRise: first.totalRise, + stepCount: first.stepCount, + }) + const tallerDeck = surface('landscape:deck', { thickness: 1.2 }) + const result = resolveStairSurfaceSnap( + placed, + { [tallerDeck.id]: tallerDeck }, + [first.position[0], 0, first.position[2] - 0.4], + first.length, + ) + expect(result?.totalRise).toBeCloseTo(1.2) + expect(result?.stepCount).toBe(8) + expect(result?.length).toBeGreaterThan(first.length) + expect(result!.position[2] + result!.length).toBeCloseTo(-1.5) +}) + +test('reconnects by the rotated high end of an existing stair', () => { + const deck = surface('landscape:deck', { thickness: 0.8 }) + const rotated = StairNode.parse({ + ...stair, + rotation: Math.PI / 2, + destinationSurfaceId: deck.id, + }) + const result = resolveStairSurfaceSnap(rotated, { [deck.id]: deck }, [-2.3, 0, 0], 0.8) + expect(result?.totalRise).toBeCloseTo(0.8) + expect(result?.rotation).toBeCloseTo(Math.PI / 2) +}) + +test('fits from the supported stair base when the deck height changes', () => { + const deck = surface('landscape:deck', { thickness: 1.2 }) + const result = resolveStairSurfaceSnap(stair, { [deck.id]: deck }, [0, 0, -2], 3, 0.2) + expect(result?.totalRise).toBeCloseTo(1) + expect(result?.stepCount).toBe(6) +}) + +test('aligns to the actual curved edge of a circular patio', () => { + const patio = surface('landscape:patio', { shape: 'circle', width: 4, depth: 4 }) + const result = resolveStairSurfaceSnap(stair, { [patio.id]: patio }, [0, 0, -2.2], 1) + expect(result).not.toBeNull() + expect(result!.position[2] + result!.length).toBeCloseTo(-2, 2) + expect(result!.rotation).toBeCloseTo(0, 1) +}) + +test('a surface-parented stair snaps in the same level frame without changing its stored base offset', () => { + const deck = surface('landscape:deck', { + position: [4, 0.3, 3], + rotation: [0, Math.PI / 2, 0], + thickness: 1.2, + }) + const level = { id: 'level_1', type: 'level', parentId: null } as AnyNode + const child = StairNode.parse({ + ...stair, + parentId: deck.id, + position: [0, -0.1, -2], + destinationSurfaceId: deck.id, + }) + const snap = resolveStairSurfaceSnap( + child, + { [deck.id]: deck, [level.id]: level }, + child.position, + 1, + ) + expect(snap).not.toBeNull() + expect(snap!.position[2] + snap!.length).toBeCloseTo(-1.5) + expect(snap!.position[1]).toBe(-0.1) + expect(snap!.totalRise).toBeCloseTo(1.3) +}) diff --git a/packages/core/src/services/stair-surface-snap.ts b/packages/core/src/services/stair-surface-snap.ts new file mode 100644 index 0000000000..fb8d6234d0 --- /dev/null +++ b/packages/core/src/services/stair-surface-snap.ts @@ -0,0 +1,165 @@ +import { floorConstructionLift } from '../lib/floor-construction-lift' +import { isNodeKindEnabled, nodeRegistry } from '../registry/registry' +import type { StairNode } from '../schema/nodes/stair' +import type { AnyNode } from '../schema/types' + +type Point = [number, number] +const SNAP_DISTANCE = 0.75 + +export type StairSurfaceSnap = { + position: [number, number, number] + rotation: number + totalRise: number + stepCount: number + length: number + surfaceId: string +} + +/** Align a straight flight's high end to a nearby registered surface edge. */ +export function resolveStairSurfaceSnap( + stair: StairNode, + nodes: Readonly>, + candidatePosition: [number, number, number], + currentLength: number, + baseElevation = candidatePosition[1], + installedPlugins?: readonly string[], +): StairSurfaceSnap | null { + if (stair.stairType !== 'straight' || stair.autoSurfaceSnap === false) return null + const parentFrame = surfaceFrame(stair.parentId, nodes) + const candidate = toFrame([candidatePosition[0], candidatePosition[2]], parentFrame) + const rotation = stair.rotation + parentFrame.rotation + const highEnd: Point = [ + candidate[0] + Math.sin(rotation) * currentLength, + candidate[1] + Math.cos(rotation) * currentLength, + ] + let closest: { distance: number; snap: StairSurfaceSnap } | null = null + for (const raw of Object.values(nodes)) { + if ( + surfaceFrame(raw.parentId, nodes).levelId !== parentFrame.levelId || + raw.visible === false || + !isNodeKindEnabled(raw.type, installedPlugins) + ) + continue + const top = nodeRegistry.get(raw.type)?.capabilities?.surfaces?.top + if (!top?.boundary) continue + const surface = raw as AnyNode & { position?: [number, number, number] } + const targetFrame = surfaceFrame(raw.parentId, nodes) + const outline = top.boundary(raw, 0).map((point) => toFrame(point, targetFrame)) + if (outline.length < 3) continue + const area = outline.reduce((sum, p, index) => { + const next = outline[(index + 1) % outline.length]! + return sum + p[0] * next[1] - next[0] * p[1] + }, 0) + for (let index = 0; index < outline.length; index++) { + const a = outline[index]!, + b = outline[(index + 1) % outline.length]! + const dx = b[0] - a[0], + dz = b[1] - a[1] + const size = Math.hypot(dx, dz) + if (size < 1e-4) continue + const project = (point: Point) => + Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dz) / (size * size))) + const lowT = project(candidate), + highT = project(highEnd) + const lowEdge: Point = [a[0] + dx * lowT, a[1] + dz * lowT] + const highEdge: Point = [a[0] + dx * highT, a[1] + dz * highT] + const lowDistance = Math.hypot(candidate[0] - lowEdge[0], candidate[1] - lowEdge[1]) + const highDistance = Math.hypot(highEnd[0] - highEdge[0], highEnd[1] - highEdge[1]) + const t = highDistance < lowDistance ? highT : lowT + const edge: Point = [a[0] + dx * t, a[1] + dz * t] + const direction = area >= 0 ? 1 : -1 + const nx = (-dz / size) * direction, + nz = (dx / size) * direction + const outside = -((candidate[0] - edge[0]) * nx + (candidate[1] - edge[1]) * nz) + if (outside < -SNAP_DISTANCE) continue + const distance = Math.min(lowDistance, highDistance) + const catchDistance = stair.destinationSurfaceId === surface.id ? 1.25 : SNAP_DISTANCE + if (distance > catchDistance || (closest && distance >= closest.distance)) continue + const height = + typeof top.height === 'function' + ? top.height(raw, { nodes, point: fromFrame(edge, targetFrame) }) + : top.height + const topElevation = + targetFrame.y + (surface.position?.[1] ?? 0) + height + floorConstructionLift(nodes, raw) + const rise = topElevation - (baseElevation + parentFrame.y) + if (!Number.isFinite(rise) || rise <= 0.01) continue + const stepCount = Math.max(1, Math.round(rise / 0.16)) + const minRun = stepCount * 0.28 + const length = Math.max(minRun, outside, 0.4) + const localPoint = fromFrame([edge[0] - nx * length, edge[1] - nz * length], parentFrame) + closest = { + distance, + snap: { + position: [localPoint[0], candidatePosition[1], localPoint[1]], + rotation: Math.atan2(nx, nz) - parentFrame.rotation, + totalRise: rise, + stepCount, + length, + surfaceId: surface.id, + }, + } + } + } + return closest?.snap ?? null +} + +export function getBoundarySurfaces( + nodes: Readonly>, + parentId: string | null | undefined, + installedPlugins?: readonly string[], +): AnyNode[] { + return Object.values(nodes).filter( + (node) => + node.parentId === parentId && + node.visible !== false && + isNodeKindEnabled(node.type, installedPlugins) && + Boolean(nodeRegistry.get(node.type)?.capabilities?.surfaces?.top?.boundary), + ) +} + +type SurfaceFrame = { + x: number + y: number + z: number + rotation: number + levelId: string | null | undefined +} + +function surfaceFrame( + parentId: string | null | undefined, + nodes: Readonly>, +): SurfaceFrame { + const frame: SurfaceFrame = { x: 0, y: 0, z: 0, rotation: 0, levelId: parentId } + const visited = new Set() + while (frame.levelId && !visited.has(frame.levelId)) { + visited.add(frame.levelId) + const parent = nodes[frame.levelId] + if (!parent || parent.type === 'level') break + const pose = parent as AnyNode & { + position?: [number, number, number] + rotation?: number | [number, number, number] + } + const angle = typeof pose.rotation === 'number' ? pose.rotation : (pose.rotation?.[1] ?? 0) + const c = Math.cos(angle), + s = Math.sin(angle) + const [x, y, z] = pose.position ?? [0, 0, 0] + const nextX = x + frame.x * c + frame.z * s + frame.z = z - frame.x * s + frame.z * c + frame.x = nextX + frame.y += y + frame.rotation += angle + frame.levelId = parent.parentId + } + return frame +} + +function toFrame([x, z]: readonly [number, number], frame: SurfaceFrame): Point { + const c = Math.cos(frame.rotation), + s = Math.sin(frame.rotation) + return [frame.x + x * c + z * s, frame.z - x * s + z * c] +} +function fromFrame([x, z]: Point, frame: SurfaceFrame): Point { + const c = Math.cos(frame.rotation), + s = Math.sin(frame.rotation) + return [(x - frame.x) * c - (z - frame.z) * s, (x - frame.x) * s + (z - frame.z) * c] +} diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 6bf654b650..242a7566ca 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -2,6 +2,7 @@ import { planWallDeletion } from '../../commands/structure/plan-wall-deletion' import type { StructurePlan } from '../../commands/structure/shared' import { withoutFloorStepOverrideKeys } from '../../lib/floor-step-finish' import { isSpaceDetectionPaused } from '../../lib/space-detection' +import { deletedSurfaceSupportPatch } from '../../lib/surface-support-references' import { nodeRegistry } from '../../registry/registry' import { validateNodeRelations } from '../../registry/validate-relations' import { @@ -1824,6 +1825,14 @@ export function planNodeDeletion( } } + for (const node of Object.values(nextNodes)) { + if (allIds.has(node.id)) continue + const patch = deletedSurfaceSupportPatch(node, allIds) + if (!patch) continue + nextNodes[node.id] = { ...node, ...patch } as AnyNode + nodesToMarkDirty.add(node.id) + } + // A deleted door takes its step paint with it, in the same undo step; every // other doorway keeps its own. for (const node of Object.values(nextNodes)) { diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index afd69ad08b..fd8b71577a 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -775,7 +775,14 @@ function migrateNodes(nodes: Record): { } if (node.type === 'stair') { - const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) + const { landscapeSurfaceId, autoLandscapeSnap, ...canonicalStair } = node + const normalized = normalizeStairNode( + migrateStairSurfaceMaterials({ + ...canonicalStair, + destinationSurfaceId: node.destinationSurfaceId ?? landscapeSurfaceId, + autoSurfaceSnap: node.autoSurfaceSnap ?? autoLandscapeSnap, + }), + ) if (normalized) { patchedNodes[id] = normalized } diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index cd16b2a12c..e955480239 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -437,3 +437,35 @@ describe('corner window partner remap', () => { expect(copyOf('window_b')).toMatchObject({ corner: { partnerId: idMap.get('window_a') } }) }) }) + +test('whole-scene and level clones remap railing supports and stair destinations', () => { + const level = makeNode('level_surface', 'level', { + children: ['slab_surface', 'fence_surface', 'stair_surface'], + }) + const slab = makeNode('slab_surface', 'slab', { parentId: level.id }) + const fence = makeNode('fence_surface', 'fence', { + parentId: level.id, + supportSurfaceNodeId: slab.id, + }) + const stair = makeNode('stair_surface', 'stair', { + parentId: level.id, + destinationSurfaceId: slab.id, + }) + const nodes = Object.fromEntries([level, slab, fence, stair].map((node) => [node.id, node])) + for (const copy of [ + cloneSceneGraph({ nodes, rootNodeIds: [level.id] }).nodes, + cloneLevelSubtree(nodes, level.id).clonedNodes, + ]) { + const entries = Object.values(copy) + const copiedSlab = entries.find((node) => node.type === 'slab')! + const copiedFence = entries.find((node) => node.type === 'fence')! + const copiedStair = entries.find((node) => node.type === 'stair')! + expect((copiedFence as unknown as { supportSurfaceNodeId: string }).supportSurfaceNodeId).toBe( + copiedSlab.id, + ) + expect((copiedStair as unknown as { destinationSurfaceId: string }).destinationSurfaceId).toBe( + copiedSlab.id, + ) + expect(copiedSlab.id).not.toBe(slab.id) + } +}) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 0b3ace7afa..b300377dc7 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -5,6 +5,7 @@ import { remapConstructionDimensionReferences, remapMeasurementReferences, } from '../lib/measurement-geometry' +import { remapSurfaceSupportReferences } from '../lib/surface-support-references' import type { AnyNode, AnyNodeId } from '../schema' import { generateId } from '../schema/base' import type { Collection, CollectionId } from '../schema/collections' @@ -433,6 +434,7 @@ export function cloneLevelSubtree( ;(cloned as Record).deckSlabId = idMap.get(cloned.deckSlabId) ?? cloned.deckSlabId } + remapSurfaceSupportReferences(cloned, idMap) remapFloorOpeningReferences(cloned, idMap) // A corner window's sides are copied together, so the copies join each other; a side copied diff --git a/packages/editor/package.json b/packages/editor/package.json index 2ac98672f8..4b116e47e0 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -7,7 +7,8 @@ "exports": { ".": "./src/index.tsx", "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx", - "./procedural-items": "./src/components/procedural-items/inspector.tsx" + "./procedural-items": "./src/components/procedural-items/inspector.tsx", + "./distribution": "./src/components/tools/distribution/distribution-public.ts" }, "scripts": { "bench:room-labels": "bun run src/components/editor/__bench__/room-label-placement.bench.ts", diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 0b2fb6c0e8..f23e2a8c79 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -2690,6 +2690,7 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({ } case 'endpoint-handle': { if (!palette) return <> + const endpointUnitsPerPixel = g.screenSized ? unitsPerPixel : handleUnitsPerPixel const handleId = makeHandleId(nodeId, g.payload) const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g) const isHovered = hoveredHandleId === handleId @@ -2715,10 +2716,10 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({ : palette.endpointHandleFill const outerRadius = (isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) * - handleUnitsPerPixel + endpointUnitsPerPixel const dotRadius = (isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) * - handleUnitsPerPixel + endpointUnitsPerPixel return ( @@ -2746,7 +2747,7 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({ r={outerRadius} stroke={hoverStroke} strokeOpacity={isActive ? 0.72 : 0.52} - strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel} + strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * endpointUnitsPerPixel} style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }} vectorEffect="non-scaling-stroke" /> diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 6d35980051..d6fa6c9074 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -19,7 +19,6 @@ import { type FenceNode, type FloorplanGeometry, type GeometryContext, - type GridEvent, type GuideNode, getRenderableSlabPolygon, getWallChordFrame, @@ -4204,6 +4203,8 @@ function FloorplanStairBuildPreviewLayer({ const point = useStairBuildPreview((s) => s.point) const rotation = useStairBuildPreview((s) => s.rotation) const rise = useStairBuildPreview((s) => s.rise) + const length = useStairBuildPreview((s) => s.length) + const stepCount = useStairBuildPreview((s) => s.stepCount) const isActive = mode === 'build' && tool === 'stair' const previewGeometry = useMemo(() => { @@ -4212,6 +4213,8 @@ function FloorplanStairBuildPreviewLayer({ } const previewSegment = { ...createSizedStairFlight(rise), + ...(length !== null ? { length } : {}), + ...(stepCount !== null ? { stepCount } : {}), id: 'sseg_floorplan_preview' as const, parentId: 'stair_floorplan_preview' as const, } @@ -4237,7 +4240,7 @@ function FloorplanStairBuildPreviewLayer({ }), ) ?? null ) - }, [isActive, point, rotation, rise, palette]) + }, [isActive, point, rotation, rise, length, stepCount, palette]) if (!previewGeometry) return null return @@ -8050,22 +8053,6 @@ export function FloorplanPanel({ useStairBuildPreview.getState().reset() return } - - const handleGridMove = (event: GridEvent) => { - // Publish to the dedicated store (deduped on the snapped point), NOT panel - // state: the stair preview lives in `FloorplanStairBuildPreviewLayer`, so a - // per-move update re-renders only that tiny leaf instead of this entire - // (~200ms) panel — the same pattern that keeps column/elevator smooth. - useStairBuildPreview - .getState() - .setPoint(getSnappedFloorplanPoint([event.localPosition[0], event.localPosition[2]])) - } - - emitter.on('grid:move', handleGridMove) - - return () => { - emitter.off('grid:move', handleGridMove) - } }, [isStairBuildActive]) useEffect(() => { @@ -9338,7 +9325,9 @@ export function FloorplanPanel({ ) const fenceGridBase = snapWallPointToGrid(planPoint) const fenceLocked = - fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] + event.altKey || + fenceSnapped[0] !== fenceGridBase[0] || + fenceSnapped[1] !== fenceGridBase[1] let snappedPoint = fenceSnapped if (fenceLocked) useAlignmentGuides.getState().clear() // Alignment lines show in every mode; the pull applies only when diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index ccc75a787e..c62d584a28 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -274,7 +274,9 @@ export function useFloorplanBackgroundPlacement({ ) const fenceGridBase = worldGridSnap(planPoint, fenceStep) const fenceLocked = - fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] + event.altKey || + fenceSnapped[0] !== fenceGridBase[0] || + fenceSnapped[1] !== fenceGridBase[1] const snappedPoint = fenceLocked ? fenceSnapped : alignFloorplanDraftPoint(fenceSnapped, { diff --git a/packages/nodes/src/shared/connection-compatibility.test.ts b/packages/editor/src/components/tools/distribution/connection-compatibility.test.ts similarity index 100% rename from packages/nodes/src/shared/connection-compatibility.test.ts rename to packages/editor/src/components/tools/distribution/connection-compatibility.test.ts diff --git a/packages/nodes/src/shared/connection-compatibility.ts b/packages/editor/src/components/tools/distribution/connection-compatibility.ts similarity index 100% rename from packages/nodes/src/shared/connection-compatibility.ts rename to packages/editor/src/components/tools/distribution/connection-compatibility.ts diff --git a/packages/editor/src/components/tools/distribution/distribution-public.ts b/packages/editor/src/components/tools/distribution/distribution-public.ts new file mode 100644 index 0000000000..8603a9f5db --- /dev/null +++ b/packages/editor/src/components/tools/distribution/distribution-public.ts @@ -0,0 +1,9 @@ +export * from './connection-compatibility' +export * from './distribution-run-contract' +export * from './distribution-run-tool' +export * from './draw-alignment' +export * from './ports' +export * from './run-cursor' +export * from './run-direction-feedback' +export * from './run-port-snap' +export * from './run-screen-direction' diff --git a/packages/nodes/src/shared/distribution-run-contract.test.ts b/packages/editor/src/components/tools/distribution/distribution-run-contract.test.ts similarity index 80% rename from packages/nodes/src/shared/distribution-run-contract.test.ts rename to packages/editor/src/components/tools/distribution/distribution-run-contract.test.ts index cf4c63cc5e..8e3d42fc38 100644 --- a/packages/nodes/src/shared/distribution-run-contract.test.ts +++ b/packages/editor/src/components/tools/distribution/distribution-run-contract.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from 'bun:test' import { offsetRunPointFromSurface, type RunSurfaceTarget } from './distribution-run-contract' -const wall = (hostId = 'wall-1', side: 'front' | 'back' = 'front'): RunSurfaceTarget => ({ +const wall = ( + hostId: `wall_${string}` = 'wall_1', + side: 'front' | 'back' = 'front', +): RunSurfaceTarget => ({ kind: 'wall', - levelId: 'level-1', + levelId: 'level_1', hostId, side, frame: { diff --git a/packages/nodes/src/shared/distribution-run-contract.ts b/packages/editor/src/components/tools/distribution/distribution-run-contract.ts similarity index 100% rename from packages/nodes/src/shared/distribution-run-contract.ts rename to packages/editor/src/components/tools/distribution/distribution-run-contract.ts diff --git a/packages/nodes/src/shared/distribution-run-tool.test.ts b/packages/editor/src/components/tools/distribution/distribution-run-tool.test.ts similarity index 100% rename from packages/nodes/src/shared/distribution-run-tool.test.ts rename to packages/editor/src/components/tools/distribution/distribution-run-tool.test.ts diff --git a/packages/nodes/src/shared/distribution-run-tool.test.tsx b/packages/editor/src/components/tools/distribution/distribution-run-tool.test.tsx similarity index 93% rename from packages/nodes/src/shared/distribution-run-tool.test.tsx rename to packages/editor/src/components/tools/distribution/distribution-run-tool.test.tsx index a50efb0d1a..e14c753226 100644 --- a/packages/nodes/src/shared/distribution-run-tool.test.tsx +++ b/packages/editor/src/components/tools/distribution/distribution-run-tool.test.tsx @@ -21,8 +21,11 @@ test.each(['duct-segment', 'pipe-segment'] as const)( configurable: true, writable: true, }) - if (!previousWindow) - Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }) + Object.defineProperty(globalThis, 'window', { + value: new EventTarget(), + configurable: true, + writable: true, + }) const wall = WallNode.parse({ parentId: 'level_test', start: [0, 0], end: [10, 0] }) let tool: ReturnType | undefined const commits: Array<[number, number, number]> = [] @@ -112,7 +115,8 @@ test.each(['duct-segment', 'pipe-segment'] as const)( useScene.setState(previousScene, true) useEditor.setState(previousEditor, true) useInteractionScope.setState(previousScope, true) - if (!previousWindow) Reflect.deleteProperty(globalThis, 'window') + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) + else 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/editor/src/components/tools/distribution/distribution-run-tool.tsx similarity index 99% rename from packages/nodes/src/shared/distribution-run-tool.tsx rename to packages/editor/src/components/tools/distribution/distribution-run-tool.tsx index 781975271d..a7348efd25 100644 --- a/packages/nodes/src/shared/distribution-run-tool.tsx +++ b/packages/editor/src/components/tools/distribution/distribution-run-tool.tsx @@ -35,14 +35,9 @@ import { import { findScreenPort, type PortScreenPoint } from './run-port-snap' import { chooseScreenDirection, screenDirectionScore } from './run-screen-direction' -export type RunPoint = [number, number, number] +export type { RunPoint, RunSurfaceFrame } from './distribution-run-contract' -export type RunSurfaceFrame = { - origin: RunPoint - normal: RunPoint - tangent: RunPoint - bitangent: RunPoint -} +import type { RunPoint, RunSurfaceFrame } from './distribution-run-contract' type RunPointerEvent = GridEvent @@ -219,10 +214,10 @@ export function projectRunToSurfaceAngleLock( ] } -type DistributionRunToolConfig = { +export type DistributionRunToolConfig = { active: boolean levelId: AnyNodeId | null - toolName: 'duct-segment' | 'pipe-segment' + toolName: string initialStart?: RunPoint | null initialConnection?: RunConnection | null getPorts: () => ScenePort[] diff --git a/packages/nodes/src/shared/draw-alignment.ts b/packages/editor/src/components/tools/distribution/draw-alignment.ts similarity index 100% rename from packages/nodes/src/shared/draw-alignment.ts rename to packages/editor/src/components/tools/distribution/draw-alignment.ts diff --git a/packages/nodes/src/shared/ports.test.ts b/packages/editor/src/components/tools/distribution/ports.test.ts similarity index 83% rename from packages/nodes/src/shared/ports.test.ts rename to packages/editor/src/components/tools/distribution/ports.test.ts index 5c74f45b6e..08d5176eb2 100644 --- a/packages/nodes/src/shared/ports.test.ts +++ b/packages/editor/src/components/tools/distribution/ports.test.ts @@ -3,8 +3,20 @@ import type { RunSurfaceTarget } from './distribution-run-contract' import { findNearestPort3D, findNearestPortXZ, type ScenePort } from './ports' const ports: ScenePort[] = [ - { nodeId: 'duct-segment_a', id: 'floor', position: [0, 0, 0], direction: [1, 0, 0] }, - { nodeId: 'duct-segment_b', id: 'wall', position: [0.4, 2, 0], direction: [1, 0, 0] }, + { + nodeId: 'duct-segment_a', + id: 'floor', + position: [0, 0, 0], + diameter: 0.1, + direction: [1, 0, 0], + }, + { + nodeId: 'duct-segment_b', + id: 'wall', + position: [0.4, 2, 0], + diameter: 0.1, + direction: [1, 0, 0], + }, ] describe('distribution port distance metrics', () => { @@ -35,6 +47,7 @@ describe('distribution port distance metrics', () => { nodeId: 'duct-segment_c', id: 'off-plane', position: [0, 1.8, 0.25], + diameter: 0.1, direction: [1, 0, 0], } expect(findNearestPort3D([0, 1.8, 0], [offPlane], 0.1, wall)).toBeNull() diff --git a/packages/nodes/src/shared/ports.ts b/packages/editor/src/components/tools/distribution/ports.ts similarity index 100% rename from packages/nodes/src/shared/ports.ts rename to packages/editor/src/components/tools/distribution/ports.ts diff --git a/packages/nodes/src/shared/run-cursor.test.ts b/packages/editor/src/components/tools/distribution/run-cursor.test.ts similarity index 100% rename from packages/nodes/src/shared/run-cursor.test.ts rename to packages/editor/src/components/tools/distribution/run-cursor.test.ts diff --git a/packages/nodes/src/shared/run-cursor.ts b/packages/editor/src/components/tools/distribution/run-cursor.ts similarity index 100% rename from packages/nodes/src/shared/run-cursor.ts rename to packages/editor/src/components/tools/distribution/run-cursor.ts diff --git a/packages/nodes/src/shared/run-direction-feedback.test.ts b/packages/editor/src/components/tools/distribution/run-direction-feedback.test.ts similarity index 100% rename from packages/nodes/src/shared/run-direction-feedback.test.ts rename to packages/editor/src/components/tools/distribution/run-direction-feedback.test.ts diff --git a/packages/nodes/src/shared/run-direction-feedback.tsx b/packages/editor/src/components/tools/distribution/run-direction-feedback.tsx similarity index 100% rename from packages/nodes/src/shared/run-direction-feedback.tsx rename to packages/editor/src/components/tools/distribution/run-direction-feedback.tsx diff --git a/packages/nodes/src/shared/run-port-snap.test.ts b/packages/editor/src/components/tools/distribution/run-port-snap.test.ts similarity index 98% rename from packages/nodes/src/shared/run-port-snap.test.ts rename to packages/editor/src/components/tools/distribution/run-port-snap.test.ts index f2f7cac083..885e6054f9 100644 --- a/packages/nodes/src/shared/run-port-snap.test.ts +++ b/packages/editor/src/components/tools/distribution/run-port-snap.test.ts @@ -7,12 +7,14 @@ const low: ScenePort = { nodeId: 'pipe-segment_low', id: 'end', position: [0, 0, 0], + diameter: 0.1, direction: [1, 0, 0], } const high: ScenePort = { nodeId: 'duct-segment_high', id: 'start', position: [0, 3, 0], + diameter: 0.1, direction: [1, 0, 0], } const camera = new PerspectiveCamera(50, 1, 0.1, 100) diff --git a/packages/nodes/src/shared/run-port-snap.ts b/packages/editor/src/components/tools/distribution/run-port-snap.ts similarity index 100% rename from packages/nodes/src/shared/run-port-snap.ts rename to packages/editor/src/components/tools/distribution/run-port-snap.ts diff --git a/packages/nodes/src/shared/run-screen-direction.test.ts b/packages/editor/src/components/tools/distribution/run-screen-direction.test.ts similarity index 100% rename from packages/nodes/src/shared/run-screen-direction.test.ts rename to packages/editor/src/components/tools/distribution/run-screen-direction.test.ts diff --git a/packages/nodes/src/shared/run-screen-direction.ts b/packages/editor/src/components/tools/distribution/run-screen-direction.ts similarity index 100% rename from packages/nodes/src/shared/run-screen-direction.ts rename to packages/editor/src/components/tools/distribution/run-screen-direction.ts diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 89102fc5bb..63fb397e79 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -60,6 +60,7 @@ import { updateSurfaceNode as writeSurfaceNode, } from '../../../lib/surface-attachment' import { isTypingTarget } from '../../../lib/typing-target' +import { createWallPointerTracker } from '../../../lib/wall-placement-pointer' import { projectAlignmentGuidesWorldToActiveBuildingLocal, resolveAlignmentForActiveBuilding, @@ -103,6 +104,7 @@ import { import { getDetachedAttachmentPreviewLift, getGridAlignedDimensions, + getSideFromNormal, snapToGrid, snapToHalf, snapUpToGridStep, @@ -822,10 +824,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Alignment guides are floor-only; clear them when the cursor moves // onto a wall / ceiling / item surface (only those paths call this). useAlignmentGuides.getState().clear() - // Roof faces carry no grab anchor, but landing on one still counts as - // anchoring elsewhere — a later return to the grabbed wall must center - // under the cursor, not restore the stale grab offset. - if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true + // A floor transition can be a hit gap; other hosted surfaces are a new anchor. + if ( + result.stateUpdate.surface && + result.stateUpdate.surface !== 'floor' && + result.stateUpdate.surface !== 'wall' + ) + grabForgotten = true Object.assign(placementState.current, result.stateUpdate) gridPosition.current.set(...result.gridPosition) @@ -949,15 +954,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // for a given host, then offsets every later move by // `start + (raw - anchor)` — mirroring the floor path and the door/window // move tools so the item tracks the grabbed point instead of teleporting - // its origin under the cursor. Reset on host change (re-seeded from the - // item's then-current position) by the surface leave handlers. - let wallDragAnchor: { - wallId: string - rawX: number - rawY: number - startX: number - startY: number - } | null = null + // its origin under the cursor. Changing hosts or faces clears the offset. + const wallPointer = createWallPointerTracker( + preserveDragOffset && grabStartPosition + ? { wallId: grabWallId, side: draftNode.current?.side, position: grabStartPosition } + : undefined, + ) let ceilingDragAnchor: { ceilingId: string rawX: number @@ -1165,6 +1167,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!cursorGroupRef.current) return const result = floorStrategy.move(getContext(), floorEvent) if (!result) return + // Attached items cannot land on the floor; its hit can be a gap in their host. + if (!asset.attachTo) grabForgotten = true // Figma-style alignment snap layered on top of the floor strategy's // grid snap: when the draft's edge lines up (on X or Z) with another @@ -1394,28 +1398,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea let wallMoveEvent = event if (preserveDragOffset && draftNode.current) { - const rawX = event.localPosition[0] - const rawY = event.localPosition[1] - if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) { - // Preserve the grab offset only on the wall the item was grabbed - // from (no teleport under the pointer at grab time). Any OTHER host - // centers the item under the cursor — a host change is already a - // jump, so tracking the pointer exactly is the expected feel, while - // re-seeding from the carried-over position (the old behavior) - // baked an arbitrary along-wall offset into the whole stay on that - // wall. Once anchored elsewhere the grab is forgotten for good, so - // re-entering the original wall centers under the cursor too. - const preserveGrab = preserveGrabOn(event.node.id, grabWallId) - wallDragAnchor = { - wallId: event.node.id, - rawX, - rawY, - startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX, - startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY, - } - } - const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX) - const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY) + preserveGrabOn(event.node.id, grabWallId) + if (grabForgotten) wallPointer.forget() + const [correctedX, correctedY] = wallPointer.resolve( + event.node.id, + getSideFromNormal(event.normal), + [event.localPosition[0], event.localPosition[1]], + ) wallMoveEvent = { ...event, localPosition: [correctedX, correctedY, event.localPosition[2]], @@ -1518,7 +1507,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallLeave = (event: WallEvent) => { - wallDragAnchor = null + wallPointer.leave() const result = wallStrategy.leave(getContext()) if (!result) return diff --git a/packages/editor/src/components/tools/registry/item-surface-move.ts b/packages/editor/src/components/tools/registry/item-surface-move.ts index 134ae3ccd9..f624155004 100644 --- a/packages/editor/src/components/tools/registry/item-surface-move.ts +++ b/packages/editor/src/components/tools/registry/item-surface-move.ts @@ -105,6 +105,8 @@ function parentWorldYaw(parentId: string | null | undefined): number { export function createRegistryItemSurfaceMove(node: AnyNode) { const capabilities = nodeRegistry.get(node.type)?.capabilities const floorPlaced = capabilities?.floorPlaced + if (capabilities?.movable?.preserveLevelAndElevation && !isFreshPlacementMetadata(node.metadata)) + return null if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) return null const original = node as AnyNode & { diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index e8c281089f..e6fd5c8460 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -17,6 +17,7 @@ import { footprintAABBFrom, type GridEvent, type GroupMoveSnapResult, + getFloorPlacedElevation, getFloorPlacedFootprints, type MovableConfig, movingFootprintAnchors, @@ -417,6 +418,9 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY)) const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } = useFreshPlacementVisibility({ node }) + const preserveLevelAndElevation = + !isFreshPlacement && + nodeRegistry.get(node.type)?.capabilities?.movable?.preserveLevelAndElevation === true // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the // cursor instead of preserving the grab offset — small connector-like // nodes read an offset drag as "lagging behind the mouse". @@ -480,7 +484,24 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { return Array.isArray(rotation) ? [rotation[0] ?? 0, y, rotation[2] ?? 0] : y } + const keepStartingElevation = (position: [number, number, number], yaw: number) => { + if (!preserveLevelAndElevation) return position + const rotation = toCommitRotation(yaw) + const currentNodes = useScene.getState().nodes + const effectiveNode = { ...node, position, rotation } as AnyNode + const lift = getFloorPlacedElevation({ + node: effectiveNode, + nodes: { ...currentNodes, [node.id]: effectiveNode }, + position, + rotation, + }) + return [position[0], originalPlanPosition[1] - lift, position[2]] as [number, number, number] + } + const currentLevelId = () => + (preserveLevelAndElevation + ? findLevelAncestorId(node.id, useScene.getState().nodes) + : null) ?? useViewer.getState().selection.levelId ?? (itemSurfaceMove ? findLevelAncestorId(node.id, useScene.getState().nodes) : node.parentId) @@ -806,10 +827,14 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { // test are plane-height independent, so the elected surface is a // single fixed point per pointer ray. const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) - supportCapRef.current = pointed?.elevation ?? null - supportSurfaceRef.current = pointed - const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0] - const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2] + supportCapRef.current = preserveLevelAndElevation ? null : (pointed?.elevation ?? null) + supportSurfaceRef.current = preserveLevelAndElevation ? null : pointed + const rawX = preserveLevelAndElevation + ? event.localPosition[0] + : (pointed?.localPoint?.[0] ?? event.localPosition[0]) + const rawZ = preserveLevelAndElevation + ? event.localPosition[2] + : (pointed?.localPoint?.[2] ?? event.localPosition[2]) revealFreshPlacement() const magnetic = isMagneticSnapActive() @@ -983,7 +1008,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { if (guides.length > 0) useAlignmentGuides.getState().set(guides) } } - if (!parentFrame && pointed?.sourceNodeId) { + if (!preserveLevelAndElevation && !parentFrame && pointed?.sourceNodeId) { const rotation = toCommitRotation(rotationRef.current) const effectiveNode = { ...((useScene.getState().nodes[node.id] ?? node) as Record), @@ -1001,6 +1026,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { }, ).position } + position = keepStartingElevation(position, rotationRef.current) const visualPosition = getVisualPosition(position) hasMovedRef.current = true setCursorPosition(visualPosition) @@ -1108,18 +1134,20 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { // The pointer cap makes the persisted host reproduce the capped // election — a drop under a deck stores the aimed-at lower slab // (or the ground), not the deck hanging above. - ...resolveSupportSlabPatch( - effectiveNode, - { - ...useScene.getState().nodes, - [node.id]: effectiveNode, - }, - { - maxElevation: supportCapRef.current, - preferredSlabId: supportSurfaceRef.current?.supportSlabId, - pinSupport: supportSurfaceRef.current?.sourceNodeId != null, - }, - ), + ...(preserveLevelAndElevation + ? {} + : resolveSupportSlabPatch( + effectiveNode, + { + ...useScene.getState().nodes, + [node.id]: effectiveNode, + }, + { + maxElevation: supportCapRef.current, + preferredSlabId: supportSurfaceRef.current?.supportSlabId, + pinSupport: supportSurfaceRef.current?.sourceNodeId != null, + }, + )), ...(isNew ? { metadata: stripPlacementMetadataFlags(node.metadata), @@ -1192,18 +1220,20 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { const committedNode = def.schema.parse({ ...reparsed, parentId: node.parentId, - ...resolveSupportSlabPatch( - { ...reparsed, parentId: node.parentId } as AnyNode, - { - ...useScene.getState().nodes, - [reparsed.id]: reparsed, - }, - { - maxElevation: supportCapRef.current, - preferredSlabId: supportSurfaceRef.current?.supportSlabId, - pinSupport: supportSurfaceRef.current?.sourceNodeId != null, - }, - ), + ...(preserveLevelAndElevation + ? {} + : resolveSupportSlabPatch( + { ...reparsed, parentId: node.parentId } as AnyNode, + { + ...useScene.getState().nodes, + [reparsed.id]: reparsed, + }, + { + maxElevation: supportCapRef.current, + preferredSlabId: supportSurfaceRef.current?.supportSlabId, + pinSupport: supportSurfaceRef.current?.sourceNodeId != null, + }, + )), }) as AnyNode useScene.temporal.getState().resume() useScene.getState().createNode(committedNode, node.parentId as AnyNodeId) @@ -1289,6 +1319,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { ) position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2]) } + position = keepStartingElevation(position, hostedPose?.rotationY ?? nextFreeRotation) lastCursorRef.current = position freeRotationRef.current = hostedPose?.rotationY ?? nextFreeRotation rotationRef.current = freeRotationRef.current @@ -1425,6 +1456,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { originalPosition, originalPlanPosition, originalRotationY, + preserveLevelAndElevation, previewRotationY, revealFreshPlacement, useAbsoluteCursorPlacement, diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index d560493783..e7128bc87f 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -52,7 +52,6 @@ import { import { FacingPoseIndicator } from './shared/facing-pose-indicator' import { SiteBoundaryEditor } from './site/site-boundary-editor' import { TerrainSculptTool } from './site/terrain-sculpt-tool' -import { StairTool } from './stair/stair-tool' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneTool } from './zone/zone-tool' @@ -113,7 +112,6 @@ function getRegistryTool(tool: Tool | null): ComponentType | null { // item / shelf / spawn now go through the registry path above. const tools: Partial> = { 'property-line': SiteBoundaryEditor, - stair: StairTool, zone: ZoneTool, } diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx index aebb139ab9..2c5347066c 100644 --- a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx +++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx @@ -1,6 +1,6 @@ import { Icon } from '@iconify/react' import type { ToolHint } from '@pascal-app/core' -import { Fragment, useEffect, useSyncExternalStore } from 'react' +import { Fragment, useEffect, useId, useState, useSyncExternalStore } from 'react' import { CONTINUATION_PROFILES, type ContinuationContext, @@ -472,6 +472,8 @@ export function ContextualHelperPanel({ notice?: string | null }) { const inStack = useInRightStack() + const [showAllHints, setShowAllHints] = useState(false) + const hintsId = useId() const modeChips = chipHints.filter((hint) => hint.chip) const hasChips = !!snapContext || !!continuationContext || modeChips.length > 0 || showPaintScope const fenceFeature = useEditor((state) => @@ -488,6 +490,9 @@ export function ContextualHelperPanel({ const actionHints = hints.filter((hint) => !isEscHint(hint)) const escHints = hints.filter(isEscHint) + const visibleHints = showAllHints + ? actionHints + : actionHints.filter((hint, index) => index === 0 || hint.active) return (
{title ? : null} - {actionHints.map((hint) => ( - - ))} +
+ {visibleHints.map((hint) => ( + + ))} +
+ {actionHints.length > 1 ? ( + + ) : null} {notice ? : null} {actionHints.length > 0 && (hasChips || escHints.length > 0) ? (
diff --git a/packages/editor/src/components/ui/helpers/helper-manager.test.tsx b/packages/editor/src/components/ui/helpers/helper-manager.test.tsx index 372bce4630..efbbe8fb7a 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.test.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.test.tsx @@ -18,7 +18,7 @@ describe('Divide HUD', () => { ).toBe('wall') }) - test('reads like the wall draft: snapping chips and the path keys', () => { + test('shows snapping controls and the primary gesture with expandable secondary shortcuts', () => { // Server rendering reads each store's initial state: the wall context starts // on Grid with a 0.5 m step. const html = renderToStaticMarkup( @@ -28,8 +28,11 @@ describe('Divide HUD', () => { ) expect(html).toContain('Snapping: Grid') expect(html).toContain('Grid: 0.50 m') - for (const label of ['Add point', 'Remove last point', 'Finish at the edge', 'Cancel']) + for (const label of ['Add point', 'More shortcuts', 'Cancel']) expect(html).toContain(label) + expect(html).toContain('aria-expanded="false"') + for (const label of ['Remove last point', 'Finish at the edge']) + expect(html).not.toContain(label) expect(ROOM_DIVIDE_HINTS.map((hint) => hint.keys)).toEqual([ ['Left click'], ['Backspace'], @@ -37,6 +40,24 @@ describe('Divide HUD', () => { ['Esc'], ]) }) + + test('keeps active secondary gestures visible while shortcuts are collapsed', () => { + const html = renderToStaticMarkup( + + ({ + ...hint, + active: hint.keys.includes('Enter'), + }))} + snapContext="wall" + /> + , + ) + for (const label of ['Add point', 'Finish at the edge', 'Cancel']) + expect(html).toContain(label) + expect(html).not.toContain('Remove last point') + expect(html).toContain('aria-expanded="false"') + }) }) describe('HUD header', () => { diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx index b2c5800188..316ebb2c91 100644 --- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx +++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx @@ -138,7 +138,7 @@ export function PanelWrapper({ ) as AnyNodeId | undefined // Subscribe to the selected node's *type* only — a string primitive that // doesn't change as fields are edited (same trick as ParametricInspector). - const selectedType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null)) + const selectedType = useScene((s) => (selectedId ? s.nodes[selectedId]?.type ?? null : null)) const installedPlugins = useScene((s) => s.installedPlugins) const extensions = useMemo(() => { // re-derive when plugin extensions register after mount (async plugin load) @@ -155,7 +155,12 @@ export function PanelWrapper({ // See `lib/inspector-card-mode.ts` for the transition table. const [activeExtensionId, setActiveExtensionId] = useState(null) // Stale ids (kind changed, plugin gated off) fall back to regular mode. - const activeExtension = resolveActiveExtension(activeExtensionId, extensions) + const primaryExtensionId = useScene((state) => { + const node = selectedId ? state.nodes[selectedId] : undefined + return node ? extensions.find((extension) => extension.primaryWhen?.(node))?.id : undefined + }) + const primaryExtension = extensions.find((extension) => extension.id === primaryExtensionId) + const activeExtension = primaryExtension ?? resolveActiveExtension(activeExtensionId, extensions) // The panel is collapsed to just its header until the user expands it. The // choice is one editor preference for every inspector (room, wall, item…), @@ -165,6 +170,10 @@ export function PanelWrapper({ useInspectorExpanded.getState().setExpanded(!next) }, []) + useEffect(() => { + if (primaryExtension) setCollapsed(false) + }, [primaryExtension, selectedId, setCollapsed]) + const applyMode = useCallback( (next: { collapsed: boolean; activeExtensionId: string | null }) => { setCollapsed(next.collapsed) @@ -176,8 +185,12 @@ export function PanelWrapper({ // Chevron / header press — collapsed → regular, regular → collapsed, // extension mode → regular (exit the extension first, stay expanded). const handleCardToggle = useCallback(() => { + if (primaryExtension) { + setCollapsed(!collapsed) + return + } applyMode(toggleCard({ collapsed, activeExtensionId })) - }, [applyMode, collapsed, activeExtensionId]) + }, [applyMode, collapsed, activeExtensionId, primaryExtension, setCollapsed]) // Folding the card forgets the active extension — extension mode is a // one-shot affordance of the header icon, not sticky panel state. @@ -282,6 +295,7 @@ export function PanelWrapper({ return (
)} - {icon && + {primaryExtension ? renderExtensionIcon(primaryExtension.icon) : icon && (typeof icon === 'string' ? ( - {title} + {primaryExtension?.title ?? title} )}
) : (

- {title} + {primaryExtension?.title ?? title}

)}
@@ -386,7 +400,7 @@ export function PanelWrapper({ ONLY that extension's content (either/or with the regular controls); the active icon (highlighted) or the chevron returns to the regular controls. */} - {extensions.map((extension) => { + {extensions.filter((extension) => extension !== primaryExtension).map((extension) => { const isActive = !collapsed && activeExtensionId === extension.id return (