diff --git a/services/pascal-editor/.agents/skills/open-pr/SKILL.md b/services/pascal-editor/.agents/skills/open-pr/SKILL.md new file mode 100644 index 000000000..d3492d713 --- /dev/null +++ b/services/pascal-editor/.agents/skills/open-pr/SKILL.md @@ -0,0 +1,132 @@ +--- +name: open-pr +description: Open a pull request on pascalorg/editor using the repo's PR template. Use when the user asks to open/create a PR, push and PR, or ship a branch in the editor repo. +allowed-tools: Bash(git *) Bash(gh *) Read +--- + +Open a pull request against `pascalorg/editor` from the current branch. + +## 1. Pre-flight + +```bash +git status # confirm working tree state +git branch --show-current # confirm we're on a feature branch, not main +git log --oneline main..HEAD +``` + +Stop if: +- The current branch is `main`. Ask the user to create a feature branch first. +- The branch has no commits ahead of `main`. Nothing to open a PR for. +- There are uncommitted changes the user hasn't asked to commit. + +Run a build sanity check if the change is non-trivial: + +```bash +bun typecheck +bun build +``` + +Don't open the PR with a broken build. + +## 2. Read the PR template + +The template is at `.github/pull_request_template.md`. Read it before composing the body — the section headings and checklist items are the source of truth, not your memory of them. + +```bash +cat .github/pull_request_template.md +``` + +Mirror the template exactly: + +- `## What does this PR do?` — one paragraph or a short bullet list. Link related issues with `Fixes #123` when applicable. +- `## How to test` — numbered, concrete reviewer steps (commands to run, what to click, expected outcome). +- `## Screenshots / screen recording` — if the change is visual, paste a recording link or note that one will be added. If purely non-visual (refactor, internal API), say so explicitly so the reviewer knows nothing is missing. +- `## Checklist` — copy the boxes verbatim, ticking the ones already verified. + +## 3. Push and open + +```bash +git push -u origin HEAD +``` + +Check for an existing PR first: + +```bash +gh pr view --json number,url,title,body 2>/dev/null +``` + +### 3a. No existing PR → create one + +Pass the body via HEREDOC to preserve markdown formatting: + +```bash +gh pr create --title "short, scope-prefixed title" --body "$(cat <<'EOF' +## What does this PR do? + + + +## How to test + +1. +2. +3. + +## Screenshots / screen recording + + + +## Checklist + +- [x] I've tested this locally with `bun dev` +- [x] My code follows the existing code style (run `bun check` to verify) +- [ ] I've updated relevant documentation (if applicable) +- [x] This PR targets the `main` branch +EOF +)" +``` + +Keep the title under ~70 characters. Use a scope prefix when there's an obvious one (`viewer:`, `core:`, `editor:`, `mcp:`). + +### 3b. PR already exists → update its description + +Do **not** recreate the PR. Refresh the existing body so it reflects everything currently on the branch, while keeping the template structure and any reviewer-meaningful state the user already set. + +1. Capture the existing body and the full branch history: + + ```bash + gh pr view --json number,body -q '.number, .body' > /tmp/existing-pr.txt + git log --oneline main..HEAD + git diff --stat main..HEAD + ``` + +2. Reconstruct the body section-by-section. Keep the four template headings in the same order (`## What does this PR do?`, `## How to test`, `## Screenshots / screen recording`, `## Checklist`). For each section: + + - **What does this PR do?** — rewrite from the *current* commits and diff on the branch, not from memory. Preserve any `Fixes #123` / `Refs #123` lines from the old body. + - **How to test** — regenerate concrete steps for the *current* behaviour. If a previous step is still valid, keep its wording; drop steps that no longer apply; add steps for new commits. + - **Screenshots / screen recording** — preserve the existing content verbatim (links, embedded images, "N/A — …"). Do not blank it out. Only change it if the user explicitly provided a new recording. + - **Checklist** — preserve the user's tick state (`[x]` vs `[ ]`) for every item that still exists in the template. Add any new template items as unchecked. + + If the old body contains extra sections the template doesn't have (e.g. a manual "Notes" block), keep them at the end. + +3. Apply the update: + + ```bash + gh pr edit --body "$(cat <<'EOF' + ## What does this PR do? + … + EOF + )" + ``` + + Use `gh pr edit --title` *only* if the branch's scope has clearly changed; otherwise leave the title alone. + +4. Print the PR URL so the user can confirm the edit. + +## 4. Report + +Return: + +- PR URL +- Title used +- Local typecheck/build status (if you ran them) +- A note for the reviewer if anything in the checklist is left unchecked diff --git a/services/pascal-editor/.agents/skills/review-architecture/SKILL.md b/services/pascal-editor/.agents/skills/review-architecture/SKILL.md new file mode 100644 index 000000000..923c36da5 --- /dev/null +++ b/services/pascal-editor/.agents/skills/review-architecture/SKILL.md @@ -0,0 +1,178 @@ +--- +name: review-architecture +description: Review a PR against the Pascal architectural rules — package boundaries (core/viewer/editor/nodes), the registry-driven composition model (def.geometry / def.renderer / def.system), legacy-dispatch regressions, the slots + world-scale-UV convention for new nodes/geometry, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture. +allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob +--- + +Architectural review for Pascal PRs. The user will provide a PR URL, branch name, or ask to review the current branch. + +## 1. Load the rules (required — do not skip) + +Read these before reviewing any diff. They are the source of truth, not your training data: + +- `wiki/architecture/layers.md` +- `wiki/architecture/systems.md` — core systems vs viewer systems, what each may do +- `wiki/architecture/renderers.md` — renderer responsibilities and prohibitions +- `wiki/architecture/tools.md` — editor tools live only in `apps/editor/components/tools/` or `packages/nodes/src//` +- `wiki/architecture/viewer-isolation.md` — viewer must stay editor-agnostic +- `wiki/architecture/node-definitions.md` — the three-checkbox composition model (`geometry` / `renderer` / `system`) +- `wiki/architecture/plugin-authoring.md` — public contract for external node packs + +Required on every review. Read the remaining pages on demand when the diff touches their subject area: + +- `wiki/architecture/selection-managers.md` +- `wiki/architecture/scene-registry.md` +- `wiki/architecture/spatial-queries.md` +- `wiki/architecture/node-schemas.md` +- `wiki/architecture/events.md` +- `wiki/architecture/interaction-scope.md` — the interaction state machine + the unified snapping/modifier convention. Read whenever the diff touches a tool, a `move-tool` / `selection` / endpoint / reshape file, `lib/interaction/**`, `lib/snapping-mode.ts`, or `use-interaction-scope`. + +If anything in the diff looks like a new dispatch surface or registry concept, also skim the live charter at `plans/editor-node-registry.md` (in the private-editor repo) — it owns the current contract and which kind sits at which migration stage. + +## 2. Fetch the diff + +```bash +# If the user gave a PR URL or number: +gh pr diff + +# If reviewing the current branch: +git diff main...HEAD +``` + +Also list changed files so you can map each to the relevant rule: + +```bash +gh pr view --json files --jq '.files[].path' +# or +git diff --name-only main...HEAD +``` + +## 3. Layer classification — do this BEFORE the checklist + +For every new file, new type, new store field, or new exported helper introduced by the diff, answer one question: **which package does this belong to — `core`, `viewer`, `editor`, or `nodes`?** If the answer is "editor" but the code lives in `packages/core` or `packages/viewer` (or vice versa), or if kind-specific code lands anywhere other than `packages/nodes/src//`, flag it as a **blocker**. This is the most common and most damaging class of violation, and the checklist below won't reliably catch it on its own — do this pass explicitly. + +### The four packages and what they own + +**`packages/core` — domain data + pure logic.** +Owns: node schemas, the scene store (`useScene`), live transforms store, core systems (wall mitering, slab polygons, space detection), event bus, plain 2D/3D math helpers, `sceneRegistry`, the registry primitives (`nodeRegistry`, `registerNode`, `loadPlugin`, `discoverPlugins`/`setPluginDiscovery`, `SceneApi`, `Plugin`/`NodeDefinition` types). Consumed by every downstream package, including read-only embeds. Must not know about: Three.js/R3F, `packages/viewer`, `apps/editor`, `packages/nodes`, any rendering or UI concept, any tool/mode/phase concept, or any *view*-specific concept (floorplan, paint preview, cursor indicators, selection outline styling). + +**`packages/viewer` — the 3D canvas, shippable standalone.** +Owns: ``, the generic `` / `` / `` / `` / `` plumbing, viewer systems (cutouts, zones, level positions, scans), the viewer store (`useViewer`) *for genuine presentation state only* (selection path, camera/level/wall/view modes, theme, display toggles, hover id), `useNodeEvents`. Consumed by both the editor and the read-only `/viewer/[id]` route. Must not know about: editor state (`useEditor`, tools, phases, modes), editor-only names baked into presentation modes (`'delete'`, `'paint-ready'`), editor-only state types (material preview, active paint target, floorplan anything), `packages/nodes`. + +**`packages/editor` (and `apps/editor`) — the editing experience.** +Owns: the tool framework (`useDragAction`, `ParametricInspector`, ``, the registry-aware dispatchers in `tool-manager.tsx` / `MoveTool` / `panel-manager.tsx` / `helper-manager.tsx`), `useEditor`, action menus, panels, the floorplan panel and its helpers, paint mode, selection-manager phase/mode logic, cursor badges, command palette, keyboard shortcuts — anything absent from the read-only viewer route. Injects itself into `` via children and props, never the reverse. Must not import from `packages/nodes`. + +**`packages/nodes` — the built-in plugin (`pascal:core`).** +Owns: one folder per node kind (`packages/nodes/src//`) containing `definition.ts`, `schema.ts`, optionally `geometry.ts` / `renderer.tsx` / `system.tsx` / `floorplan.ts` / `tool.tsx` / `move-tool.tsx` / `panel.tsx` / `parametrics.ts` / `preview.tsx`. Exports `builtinPlugin`. Depends on `editor`, `viewer`, and `core` via their public surfaces — the same surfaces a third-party plugin uses (peer-dep style). **Nothing in `core/`, `viewer/`, or `editor/` may import from `@pascal-app/nodes`.** The dependency arrow is one-way: framework code consults `nodeRegistry`, never reaches into a specific kind's folder. + +### Triggers that mean "this is probably in the wrong package" + +1. **Would the read-only `/viewer/[id]` route need this?** If no, it belongs in `apps/editor` / `packages/editor`. +2. **Does the name contain an editor-specific word?** (`Floorplan`, `Paint…`, `Draft…`, `Marquee`, `CursorBadge`, `HoverMode`, `…Tool`, `Moving…`, `Curving…`.) Default to editor and justify loudly if it's anywhere else. +3. **Does the type or field reference a tool/mode/phase vocabulary?** (`'delete'`, `'paint-ready'`, `'material-paint'`, `'site'`/`'structure'`/`'furnish'`, `'build'`/`'edit'`.) Belongs in `useEditor`, not `useViewer` or core. +4. **Does the helper compute something only a 2D editor view needs?** (Floorplan transforms, measurement offsets, SVG path builders, marquee bounds scoped to floorplan.) Editor. Generic 2D geometry that any view could use (polygon math, rotation, clamping, line thickening) can live in core *as long as its names are generic* — no `Floorplan` prefix. +5. **Does a new store field have a setter that no part of the target layer ever calls?** (e.g. `setMaterialPreview` in `useViewer` that only the editor would ever invoke.) That's a layering smell — the state belongs in the caller's layer. +6. **Does the new file mention a specific kind by name?** (`door-…`, `wall-…`, `item-…`, etc.) Then it belongs in `packages/nodes/src//`, **not** under `packages/viewer/src/components/renderers//`, `packages/viewer/src/systems/.ts`, `packages/editor/src/components/tools//`, or `packages/editor/src/components/ui/panels/-panel.tsx`. Those legacy locations were deleted at Phase 6 cleanup — reintroducing one is a regression to the dispatch model. +7. **Does an `import` line read `from '@pascal-app/nodes'` inside `core/`, `viewer/`, or `editor/`?** Blocker. The Biome `noRestrictedImports` rule already bans this; if it slipped through, the framework is reaching down into the plugin. + +Write the classification down before writing findings. If core gains "Floorplan" types, the viewer gains paint-mode vocabulary, a renderer grows editor awareness, or a kind-specific file appears outside `packages/nodes/src//` — those are the blockers to lead with, not downstream symptoms. + +## 4. Review checklist + +### A. Package boundaries + +- `packages/viewer/**` does not import from `@pascal-app/editor`, `apps/editor`, or `@pascal-app/nodes`, and does not reference `useEditor`, tool state, phase, or mode. +- `packages/core/**` does not import Three.js, react-three-fiber, `@pascal-app/viewer`, `@pascal-app/editor`, or `@pascal-app/nodes`. +- `packages/editor/**` does not import from `@pascal-app/nodes`. +- `packages/core/**` does not introduce types or helpers named after an editor view (`Floorplan*`, `Paint*`, `Draft*`). Generic plan-geometry helpers are fine; view-specific vocabulary is not. +- No new `case '':` clauses (or equivalent kind-specific branching keyed on `node.type`) inside `packages/viewer/**` or `packages/editor/**`. Phase 6 deleted these; the dispatch happens via `nodeRegistry`. The exceptions left in tree are `treeNodeByType` (a lookup *map*, not a switch) and unit-formatting switches (`centimeters` / `feet` / `inches`). Any new `case 'door'|'wall'|'item'…` in a framework package is a blocker — the behavior belongs on the kind's `NodeDefinition`. +- Tools mutate `useScene` (committed state) and `useLiveTransforms` (ephemeral drag state); direct `sceneRegistry` mesh transforms are allowed only under the live-drag exception in `wiki/architecture/tools.md`. No business logic, no imports from `packages/viewer`. + +### B. Node registry & composition (`packages/nodes`) + +If the PR adds or modifies a node kind, check against `wiki/architecture/node-definitions.md` and `wiki/architecture/plugin-authoring.md`: + +- **Three independent fields**: `def.geometry?: (node, ctx) => Object3D`, `def.renderer?: () => Promise<{ default }>`, `def.system?: () => Promise<{ default }>`. There is no discriminator — presence is participation. Setting all three is fine if the kind genuinely needs them; setting a `def.system` whose only job is to rebuild geometry on dirty is a smell — collapse to `def.geometry` and let `` do the work. +- **Builders must be pure.** A `def.geometry` function must not import `useScene`, must not mutate the store, and must not depend on React context. Read other nodes via `GeometryContext` (`ctx.resolve` / `ctx.children` / `ctx.siblings` / `ctx.parent`). +- **Builders emit local-space children.** `` binds `` in JSX. A builder that bakes world position into vertex coords, or a system that imperatively writes `group.position` / `group.rotation`, will desync R3F's prop binding — the node will snap to `(0,0,0)` after rebuild. Flag any imperative `group.position.set(...)` inside `def.geometry` or a registered system. (Tool-driven `sceneRegistry.nodes.get(id).position.set(...)` during a live drag is fine and is the documented pattern — see hook hygiene below.) +- **Tag geometry-built children.** `` only disposes children carrying `userData.__fromGeometry = true`. Custom systems that imperatively add children to a registered group must follow the same convention if the group can host React-mounted children (e.g. shelf surfaces hosting items). +- **One registered mesh per node ID.** If a custom renderer mounts multiple objects, register the parent group (or whichever object the system needs to address via `sceneRegistry.nodes.get(id)`). +- **Previews must clone cached materials.** If `def.preview` calls the geometry builder and then sets `material.opacity = 0.5`, but the builder caches materials at module scope (most do, keyed on `material` / `materialPreset`), the mutation leaks into every committed instance. Clone, mutate the clone, reassign `mesh.material`, dispose only the clone on unmount. Reference: `nodes/src/shelf/preview.tsx`. +- **Schema changes must keep old scenes loadable.** Any diff that adds, renames, removes, or retypes a property on a node schema needs a load path for scenes saved before the change (parsed through `AnyNode` in `SceneState.setScene`). A *new* field needs a Zod `.default()` / `.optional()`. A *rename / removal / retype* needs a `migrateNodes` entry in `packages/core/src/store/use-scene.ts` that rewrites the legacy shape before parse — a `.default()` alone silently drops the old value. A schema diff that does neither is a blocker: it breaks every existing scene. See `wiki/architecture/node-schemas.md` § Schema Evolution. +- **Host kinds need `children` on the schema.** If `def.relations.hosts` is set, the schema must declare `children: z.array(z.string()).default([])` (and `migrateNodes` must patch existing scenes). Otherwise `useScene.createNode(child, parentId)` writes a `parent.children` entry into nothing and the host never sees the new child. +- **Movable opt-in.** `MoveTool` dispatches to `MoveRegistryNodeTool` only when `def.capabilities.movable` is set. Kinds with bespoke move semantics (wall endpoint drag with linked-wall cascade, slab vertex edit, etc.) deliberately omit `movable` and supply `def.affordanceTools.move` instead. Force-routing a bespoke-move kind through generic dispatch (`nodeRegistry.has(kind)` instead of `def.capabilities.movable`) is a regression — call it out. The bug history is documented in `plans/editor-node-registry.md` ("Capability-driven move dispatch"). +- **Paint dispatch lives on `def.capabilities.paint`.** A paintable kind declares `resolveRole` / `buildPatch` / `applyPreview` (+ optional `getEffectiveMaterial`) on `PaintCapability`; the editor's selection-manager routes hover / click / preview through the generic dispatcher. A PR that adds an `if (node.type === '')` arm to paint-mode handling, paint-preview application, or material picker resolution is a regression — the behaviour belongs on the kind's `paint` capability. See `packages/core/src/registry/types.ts` (`PaintCapability`). +- **Slots + world-scale UVs for paintable surfaces.** A new kind (or geometry change) that exposes paintable parts must follow the unified slot convention, not reinvent it: + - **Paintable parts are slots, carried on the node.** Overrides live in a `slots` record (`slotId → MaterialRef`, `scene:`/`library:`) on the schema, resolved via `def.capabilities.paint` — not ad-hoc per-surface `material` / `materialPreset` fields, and not a parallel store. A new paintable kind whose schema lacks `slots` (or whose duplicate / preset / clone path drops it) is a blocker: it silently loses painted materials. (Slots are plain data — generic clone/parse preserves them; bespoke draft-rebuild placement paths must thread them through explicitly. Reference bug: item duplicate rebuilt the draft from `asset` and dropped `slots`.) + - **Texturable geometry emits UVs in metres (1 UV unit = 1 m).** Any `def.geometry` producing a surface a finish can tile onto must generate UVs at the same world scale walls / slabs / roofs use, because catalog finishes set `repeat` as tiles-per-metre. Unitless, bounding-box-normalised, or hardcoded UVs that don't scale with the surface are a blocker — finishes won't tile consistently. Flat-colour-only surfaces need no UVs. GLB item authoring follows the same contract via `slot_`-prefixed materials (case-insensitive, `slot_` → slot id). See `wiki/architecture/materials-and-themes.md` § "Texture world scale" and `wiki/architecture/item-authoring.md`. +- **Floor elevation lives on `def.capabilities.floorPlaced`.** Kinds that rest on a level and lift over overlapping slabs declare a `footprint` (and optional `applies` predicate) on `FloorPlacedConfig`; the generic `` writes `slabElevation + node.position[1]` onto the registered mesh on each dirty mark. A new per-kind `useEffect` / per-kind system that recomputes Y from slab overlap is a regression — the per-kind block was lifted out of `ItemSystem` in Phase 6.1. See `packages/core/src/registry/types.ts` (`FloorPlacedConfig`). +- **Render-mode behaviour goes through `def.surfaceRole`.** Solid / Rendered / Clay viewer modes look up the kind's `surfaceRole` on `NodeDefinition` to pick the right material strategy (clay overrides, edge passes, theme overlays). New `if (node.type === '')` arms inside the render-mode pipeline or theme application are a regression — the behaviour belongs on `def.surfaceRole`. See `packages/core/src/registry/types.ts:529`. +- **Capability names must describe verbs, not host kinds.** `movable` / `paint` / `floorPlaced` / `cuttable` / `selectable` describe *what the node does*. A new capability named after a host kind (`slabAccessory`, `wallAccessory`, `siteAccessory`, anything `Xaccessory` / `Xhosted` shaped) couples the registry's type surface to one specific host and reads as precedent for the next reviewer. Blocker. Push back: generalise into a paired *host-side* capability ("I merge subtractive accessories from my children") + *accessory-side* capability ("I provide a cut geometry, cascade my dirty mark to my host's parent"). The single existing case — `capabilities.roofAccessory` (`packages/core/src/registry/types.ts:791`, consumed by `packages/viewer/src/systems/roof/roof-system.tsx`) — is documented tech debt; do not extend the pattern. +- **Per-kind files in legacy locations are a regression.** New `viewer/src/components/renderers//*`, `viewer/src/systems/-system.tsx`, `editor/src/components/tools//*`, `editor/src/components/ui/panels/-panel.tsx`, `editor/src/components/ui/helpers/-helper.tsx`, or inline `useMemo` floor-plan entry-builders inside `editor/src/components/editor/floorplan-panel.tsx` — all of these were systematically deleted at Phase 6. The behavior belongs on the kind's `NodeDefinition` (`def.renderer` / `def.system` / `def.geometry` / `def.tool` / `def.affordanceTools` / `parametrics.customPanel` / `def.toolHints` / `def.floorplan`). +- **Floor-plan output via `def.floorplan`.** New per-kind floor-plan rendering must return `FloorplanGeometry` from `def.floorplan(node, ctx)` and be rendered by ``. New inline branches in `floorplan-panel.tsx` are a blocker. +- **Plugin contract surface.** A PR that extends the v1 plugin surface — adding `plugin.materials`, `plugin.systems`, `plugin.panels`, or making plugins extend host stores (`useScene` / `useEditor` / `useViewer`) — is out of scope for the v1 contract documented in `wiki/architecture/plugin-authoring.md`. Either the change belongs as a new field on `NodeDefinition` (additive, doesn't bump `apiVersion`) or it needs its own plan. + +### C. Hook hygiene (`useEditor`, `useScene`, `useViewer`) + +- Stores hold state + setters only. No business logic, side effects, async work, or derived computations inside the store definition. +- Derived values belong in selectors or systems, not in the store body. +- No cross-store coupling: a store's action should not call another store's actions inside itself. +- New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`. +- **Node code does not import `useScene` directly.** A kind's geometry / system / tool should read and write through `SceneApi` (passed in by the framework) or `GeometryContext`. Direct `useScene.getState()` calls inside `packages/nodes/src//` are a smell — they bypass the registry's IoC point and make the code harder to test. +- **Live drag motion is imperative, not store-driven.** Tools must not call `useLiveTransforms.set(...)` per `grid:move` tick to animate registered parametric kinds — the selector path doesn't reliably re-render and the mesh visibly disappears mid-drag. Use `sceneRegistry.nodes.get(node.id)?.position.set(x, y, z)` instead, and commit once at the end via `useScene.temporal.getState().resume() → updateNode → pause()`. The reference implementation is `MoveRegistryNodeTool`. This is the *only* sanctioned use of imperative mesh transforms by a tool; flag any other location that does the same. +- **Data-driven drags preview via `useLiveNodeOverrides`, never per-tick `useScene`.** A kind whose geometry is recomputed from data fields (wall `start`/`end`, opening host-cut, endpoint reshape) previews by publishing field patches to `useLiveNodeOverrides` (merged by `getEffectiveWall` / `getEffectiveNode`), writing the scene store **once on commit**. A tool that calls `useScene.updateNodes`/`updateNode` on `grid:move` (or any per-pointer-move tick) is a **blocker** — it swaps the `nodes` map ref and re-renders every `useScene(s => s.nodes)` subscriber app-wide each frame (`markDirty` per tick is fine). Grep tell: `updateNode(s)?(` in an `onGridMove`/`onMove`/`applyPreview` path under `packages/nodes/src//`. See `wiki/architecture/tools.md` § "Data-driven live drag". + +### D. Selector performance + +- Top-level components (pages, layouts, providers, `` siblings) must not subscribe to large or frequently-changing slices — e.g. `useScene(s => s.nodes)`, `useScene(s => s)`. Flag these: they re-render the whole subtree on every mutation. +- Selectors that return new object or array references each call (e.g. `s => ({ a: s.a, b: s.b })`, `s => s.items.filter(...)`) without a custom equality function (shallow or custom) are re-render hazards. +- Prefer subscribing by ID deep in the tree (one node per renderer) over subscribing to the full collection high up. +- Inside a `` (legacy or `parametrics.customPanel`-mounted), avoid `useScene(s => s.nodes[selectedId])` as a callback dep — it changes every tick and pushes `useCallback` into infinite-loop territory. The recipe is in `plans/editor-node-registry.md` under "Panel slider-drag fix recipe". +- **Per-node list renderers subscribe per-node, not to the whole live Map.** A list that draws one child per node (`FloorplanRegistryLayer` → `FloorplanRegistryEntry`) must have each child subscribe to its **own** slice (`useLiveTransforms(s => s.transforms.get(id))` / `overrides.get(id)`) and be `memo`'d with referentially stable props; the parent subscribes only to the stable id list. Subscribing the parent or a child to the whole `transforms`/`overrides` Map, dropping a `memo`, or passing unstable props re-renders all N children every drag tick — a flood that type-checks and passes tests. Sibling invalidation goes through a per-node epoch, not a whole-layer re-render. See `wiki/architecture/tools.md` § "Floorplan registry: per-node subscriptions". + +### E. Separation of concerns + +- Viewer and core stay unaware of editor-specific concepts (tools, phases, active modes, editor UI state, view-specific helpers). +- Editor-only overlays and systems are injected as children of ``, not added inside the viewer package. +- **Editor overlay meshes must carry the editor layer.** Any new `` / `` / `` / `` an editor overlay or tool component adds to the 3D scene (gizmos, handles, guides, previews, cursor meshes, marquees) must set `layers={EDITOR_LAYER}` — or `GRID_LAYER` for the ground grid, `ZONE_LAYER` for zone fills. The thumbnail/snapshot camera renders only layer 0, so an untagged overlay leaks into exports (and gets inked / SSGI-darkened in the live view). Flag any overlay-component primitive that omits the layer assignment. See `wiki/architecture/layers.md`. +- New node types are added by creating one folder under `packages/nodes/src//` and registering its definition in `builtinPlugin.nodes`. Adding to a hand-maintained list elsewhere is a sign the registry hasn't absorbed that surface yet — check `plans/editor-node-registry.md` § "Known un-shimmed hardcoded lists" before assuming it's a violation. +- `AnyNode` is hand-maintained for now (full runtime derivation would lose static typing); `packages/nodes/src/index.test.ts` is the drift gate. If a PR adds a kind to `AnyNode` without adding it to `builtinPlugin.nodes` (or vice versa), the parity test catches it — but flag it in review too. + +### F. Interaction scope, snapping & modifiers + +Apply when the diff touches a tool, a `move-tool` / `selection` / endpoint / reshape file, `lib/interaction/**`, `lib/snapping-mode.ts`, or `use-interaction-scope`. Source of truth: `wiki/architecture/interaction-scope.md` and `wiki/architecture/tools.md`. + +- **No new `useEditor` interaction flag.** "What the user is doing" is owned by `useInteractionScope` (`begin` / `update` / `end` / `endIf`). A new `useEditor` boolean for an in-flight interaction (`moving…`, `curving…`, `dragging…`, `editing…`, `…InFlight`) is a **blocker** — it goes through the scope. The legacy mirror flags are being retired, not extended. +- **Snapping is mode-driven; Shift is not a bypass.** A tool / `move-tool` / `selection` file that reads `event.shiftKey`, `event.nativeEvent?.shiftKey`, or `modifiers.shiftKey` to **bypass snapping** (raw cursor, skip grid, skip angle) is a **blocker** — the convention is Shift = *cycle the mode*, Alt = force/free. Snap state must come from `isGridSnapActive()` / `isMagneticSnapActive()` / `isAngleSnapActive()`. Grep tell: `shiftKey` near a snap / step / `projectToAngleLock` / alignment expression in `packages/nodes/src//{tool,move-tool,selection}.tsx`. (Shift for *multi-select* in select mode, or a documented topology opt-out, is fine — confirm which it is.) +- **No hardcoded, ungated grid step.** A quantize that isn't gated on `isGridSnapActive()` — always `useEditor.getState().gridSnapStep`, or a constant `WALL_GRID_STEP` / `0.5` / `getSegmentGridStep()` applied unconditionally — ignores the active mode and is a **blocker**. The gated form is `const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0`. +- **Snappable kinds declare `snapProfile`.** A kind whose tool snaps but whose `NodeDefinition` omits `snapProfile` (`'item' | 'structural'`) gets no contextual chip and the wrong default mode-set — flag it (suggestion, blocker if it ships a bespoke per-kind snapping switch instead). +- **Bespoke movers must not open a `moving` scope.** `useMovingNode()` reads the scope, and `tool-manager` mounts the generic `MoveRegistryNodeTool` whenever it's non-null. A bespoke `move-tool.tsx` that calls `begin(movingScope(...))` or `setMovingNode(node)` re-creates the dual-path double-handling (FPS collapse / teleport on move). **Blocker.** Mode-driven snapping inside a bespoke mover must resolve the mode without a global `moving` / `reshaping` scope (see `interaction-scope.md` § "Snapping mode & modifiers"). +- **`event.altKey` is not an alignment bypass.** A drafting/preview path that reads `event.altKey` to suppress Figma-alignment is a **blocker** in any **new** or **touched** tool — alignment follows the magnetic snap mode (`bypass: !isMagneticSnapActive()`). Alt is force/free for placement/move; it is **not** a snap/alignment modifier. The one sanctioned Alt use outside force is the **wall/fence chain-mode toggle** (clean Alt-tap → `cycleWallChainMode` / `cycleFenceChainMode`, via `hooks/use-keyboard.ts` `isChainModeContext()`), allowed only because wall/fence drafting has no force role. Grep tell: `event.altKey` near an `align` / `bypass` expression in a `tool.tsx` / floorplan preview path. +- **Known-legacy exceptions (migrate on touch).** Tracked debt in `plans/editor-placement-interaction-overhaul.md`; a PR that **touches** one must migrate it, not extend it; a **new** tool on either legacy pattern is a blocker regardless. (1) `shiftKey` snap-bypass in the MEP move/endpoint tools (`packages/nodes/src/{duct-segment,pipe-segment,liquid-line,lineset,duct-fitting}/{move-tool,selection}.tsx`). (2) `altKey` alignment-bypass in the roof / polygon / slab pointer-move previews (`components/editor/floorplan-panel.tsx`) and the `resolveSlabPlanPointSnap` / `resolveCeilingPlanPointSnap` paths. **Already migrated — do not regress:** wall + fence drafting (both modifier patterns) and `zone` drafting (`components/tools/zone/zone-tool.tsx` — mode-driven grid/angle gates, no Shift bypass). + +## 5. Output format + +Group findings by severity: + +- **Blocker** — violates a rule in `wiki/architecture/` or breaks a layer/package boundary. Must be fixed before merge. +- **Suggestion** — likely problem, worth discussing. Not a hard block. +- **Nit** — minor, optional. + +For each finding, include: + +1. File and line: `path/to/file.ts:42` +2. The offending snippet (short — 1–5 lines) +3. The rule it violates, linked to the wiki page (e.g. `wiki/architecture/viewer-isolation.md`, `wiki/architecture/node-definitions.md`) +4. A concrete proposed fix + +Skip formatting, import ordering, and anything CI already covers. + +If the PR fully complies, say so explicitly — do not invent nits to appear thorough. + +## 6. Final summary + +End with: + +- Blocker count, suggestion count, nit count +- One-sentence verdict: ready to merge / needs changes / needs discussion +- If blockers exist, list the files the author should open first diff --git a/services/pascal-editor/.claude/settings.json b/services/pascal-editor/.claude/settings.json new file mode 100644 index 000000000..f5ad70468 --- /dev/null +++ b/services/pascal-editor/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npx turbo:*)" + ] + } +} diff --git a/services/pascal-editor/.claude/skills b/services/pascal-editor/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/services/pascal-editor/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/services/pascal-editor/.codex/skills b/services/pascal-editor/.codex/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/services/pascal-editor/.codex/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/services/pascal-editor/.cursor/skills b/services/pascal-editor/.cursor/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/services/pascal-editor/.cursor/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/services/pascal-editor/.env.example b/services/pascal-editor/.env.example new file mode 100644 index 000000000..1c1b5eaa2 --- /dev/null +++ b/services/pascal-editor/.env.example @@ -0,0 +1,33 @@ +# Optional environment variables — the editor works without any of these + +# Google Maps API key (enables address search) +# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_api_key + +# Dev server port (default: 3000) +# PORT=3000 + +# AI design agent. The endpoint must implement the OpenAI-compatible chat API. +# PASCAL_AGENT_API_KEY=your_api_key +# PASCAL_AGENT_BASE_URL=https://api.openai.com/v1 +# PASCAL_AGENT_MODEL=gpt-4.1-mini + +# Optional agent safety limits. +# PASCAL_AGENT_MAX_STEPS=18 +# PASCAL_AGENT_STEP_TIMEOUT_MS=90000 +# PASCAL_AGENT_TOTAL_TIMEOUT_MS=300000 + +# Required in production. Configure the reverse proxy to overwrite this header +# before forwarding requests to Pascal; never expose the value to browser code. +# PASCAL_AGENT_PROXY_SECRET=replace_with_a_long_random_value + +# Optional when Bun or the built MCP executable are not discoverable automatically. +# PASCAL_MCP_COMMAND=/absolute/path/to/bun +# PASCAL_MCP_ENTRY=/absolute/path/to/packages/mcp/dist/bin/pascal-mcp.js + +# Shared scene storage used by the editor and each isolated Pascal MCP process. +# PASCAL_DATA_DIR=/absolute/path/to/pascal-data + +# Hosted integration. The reverse proxy must authenticate the request, strip +# any client-supplied user header, and inject the trusted host user id. +# NEXT_PUBLIC_PASCAL_BASE_PATH=/pascal-service +# PASCAL_REQUIRE_HOST_USER=true diff --git a/services/pascal-editor/.github/ISSUE_TEMPLATE/bug_report.yml b/services/pascal-editor/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..9da4e96a2 --- /dev/null +++ b/services/pascal-editor/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: 🐛 Bug Report +description: Report something that isn't working correctly +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix the issue. + + - type: textarea + id: description + attributes: + label: What happened? + description: A clear description of the bug. + placeholder: Describe what went wrong... + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: How can we reproduce the issue? + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + + - type: input + id: browser + attributes: + label: Browser & OS + description: e.g. Chrome 120, macOS 14 + placeholder: Chrome 120, macOS 14 + validations: + required: false + + - type: textarea + id: screenshots + attributes: + label: Screenshots or screen recordings + description: | + A short screen recording is the fastest way to show us the bug. + Drag and drop a video file or paste a link — even a quick clip helps a lot. + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional context + description: Any other information that might be helpful (console errors, related issues, etc.) + validations: + required: false diff --git a/services/pascal-editor/.github/ISSUE_TEMPLATE/config.yml b/services/pascal-editor/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..48a4f859d --- /dev/null +++ b/services/pascal-editor/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 Questions & Help + url: https://github.com/pascalorg/editor/discussions/categories/q-a + about: Ask questions and get help from the community + - name: 💡 Ideas & Discussion + url: https://github.com/pascalorg/editor/discussions/categories/ideas + about: Share ideas and discuss features before opening a formal request diff --git a/services/pascal-editor/.github/ISSUE_TEMPLATE/feature_request.yml b/services/pascal-editor/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..268c4ee25 --- /dev/null +++ b/services/pascal-editor/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: ✨ Feature Request +description: Suggest a new feature or improvement +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + We love hearing ideas from the community! Please describe what you'd like to see in Pascal Editor. + + - type: textarea + id: problem + attributes: + label: Problem or motivation + description: What problem does this solve? Why do you want this? + placeholder: I'm always frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: How do you think this should work? + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any workarounds or alternative approaches you've thought about? + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional context + description: Mockups, screenshots, references to other tools, etc. + validations: + required: false diff --git a/services/pascal-editor/.github/copilot-instructions.md b/services/pascal-editor/.github/copilot-instructions.md new file mode 120000 index 000000000..be77ac83a --- /dev/null +++ b/services/pascal-editor/.github/copilot-instructions.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/services/pascal-editor/.github/pull_request_template.md b/services/pascal-editor/.github/pull_request_template.md new file mode 100644 index 000000000..bd7092d10 --- /dev/null +++ b/services/pascal-editor/.github/pull_request_template.md @@ -0,0 +1,27 @@ +## What does this PR do? + + + +## How to test + + + +1. +2. +3. + +## Screenshots / screen recording + + + +## Checklist + +- [ ] I've tested this locally with `bun dev` +- [ ] My code follows the existing code style (run `bun check` to verify) +- [ ] I've updated relevant documentation (if applicable) +- [ ] This PR targets the `main` branch diff --git a/services/pascal-editor/.github/workflows/ci.yml b/services/pascal-editor/.github/workflows/ci.yml new file mode 100644 index 000000000..fdacf1586 --- /dev/null +++ b/services/pascal-editor/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint & format check + run: bun run check + + - name: Type check + run: bun run check-types diff --git a/services/pascal-editor/.github/workflows/mcp-ci.yml b/services/pascal-editor/.github/workflows/mcp-ci.yml new file mode 100644 index 000000000..c11fed1e5 --- /dev/null +++ b/services/pascal-editor/.github/workflows/mcp-ci.yml @@ -0,0 +1,52 @@ +name: mcp-ci + +on: + push: + branches: [main] + paths: + - 'packages/mcp/**' + - 'packages/core/**' + - 'apps/editor/app/api/scenes/**' + - 'apps/editor/lib/scene-*' + - 'apps/editor/package.json' + - 'bun.lock' + - '.github/workflows/mcp-ci.yml' + pull_request: + paths: + - 'packages/mcp/**' + - 'packages/core/**' + - 'apps/editor/app/api/scenes/**' + - 'apps/editor/lib/scene-*' + - 'apps/editor/package.json' + - 'bun.lock' + - '.github/workflows/mcp-ci.yml' + +jobs: + ci: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.0 + + - name: Install + run: bun install --frozen-lockfile + + - name: Build core + run: bun run --cwd packages/core build + + - name: Build mcp + run: bun run --cwd packages/mcp build + + - name: Test mcp + run: bun test --cwd packages/mcp + + - name: Test editor scene API + run: bun test apps/editor/lib/scene-store-server.test.ts apps/editor/lib/scene-api-security.test.ts + + - name: Biome check + run: bunx biome check packages/mcp apps/editor/lib/scene-store-server.ts apps/editor/lib/scene-api-security.ts apps/editor/app/api/scenes diff --git a/services/pascal-editor/.github/workflows/release.yml b/services/pascal-editor/.github/workflows/release.yml new file mode 100644 index 000000000..15021c1da --- /dev/null +++ b/services/pascal-editor/.github/workflows/release.yml @@ -0,0 +1,221 @@ +name: Release + +on: + workflow_dispatch: + inputs: + package: + description: "Package to release" + required: true + type: choice + options: + - core + - viewer + - editor + - mcp + - ifc-converter + - all + bump: + description: "Version bump (use 'none' to publish current version as-is)" + required: true + type: choice + options: + - patch + - minor + - major + - none + dry-run: + description: "Dry run (no publish)" + required: false + type: boolean + default: false + +jobs: + release: + runs-on: ubuntu-latest + environment: npm + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump versions and sync inter-package references + run: | + BUMP=${{ inputs.bump }} + TARGET=${{ inputs.package }} + + bump_version() { + local v=$1 + IFS='.' read -r MAJ MIN PAT <<< "$v" + if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi + if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi + if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi + if [ "$BUMP" = "none" ]; then echo "$v"; return; fi + echo "$MAJ.$MIN.$PAT" + } + + # Track new versions in shell-local vars. + # NOTE: $GITHUB_ENV writes don't surface within the same step, so the + # peerDeps sync below must use shell vars, not env indirection. + declare -A NEW_VERSIONS + + for pkg in core viewer editor mcp ifc-converter; do + if [ "$TARGET" = "$pkg" ] || [ "$TARGET" = "all" ]; then + CUR=$(jq -r '.version' packages/$pkg/package.json) + NEW=$(bump_version "$CUR") + jq --arg v "$NEW" '.version = $v' packages/$pkg/package.json > tmp.json && mv tmp.json packages/$pkg/package.json + NEW_VERSIONS[$pkg]=$NEW + UPPER=$(echo "$pkg" | tr '[:lower:]' '[:upper:]' | tr '-' '_') + # Also export for the publish/commit/tag steps that follow. + echo "${UPPER}_VERSION=$NEW" >> $GITHUB_ENV + echo "Bumped @pascal-app/$pkg: $CUR → $NEW" + fi + done + + # Sync inter-package references in peerDependencies and devDependencies. + # Anything that references a bumped @pascal-app/* package is updated to ^NEW. + for pkg in core viewer editor mcp ifc-converter; do + FILE=packages/$pkg/package.json + for dep in core viewer editor mcp ifc-converter; do + VAL="${NEW_VERSIONS[$dep]}" + [ -z "$VAL" ] && continue + jq --arg name "@pascal-app/$dep" --arg v "^$VAL" ' + if .peerDependencies[$name] then .peerDependencies[$name] = $v else . end + | if .devDependencies[$name] then .devDependencies[$name] = $v else . end + ' "$FILE" > tmp.json && mv tmp.json "$FILE" + done + done + + echo "=== @pascal-app/* refs after sync ===" + for pkg in core viewer editor mcp ifc-converter; do + echo "--- packages/$pkg/package.json ---" + jq '{ peerDependencies: (.peerDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))), devDependencies: (.devDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))) }' packages/$pkg/package.json + done + + - name: Build & publish core + if: inputs.package == 'core' || inputs.package == 'all' + working-directory: packages/core + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + bun run build + if [ "${{ inputs.dry-run }}" = "true" ]; then + echo "🏜️ Dry run — would publish @pascal-app/core@$CORE_VERSION" + npm publish --dry-run --access public + else + npm publish --access public + echo "📦 Published @pascal-app/core@$CORE_VERSION" + fi + + - name: Build & publish viewer + if: inputs.package == 'viewer' || inputs.package == 'all' + working-directory: packages/viewer + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + bun run build + if [ "${{ inputs.dry-run }}" = "true" ]; then + echo "🏜️ Dry run — would publish @pascal-app/viewer@$VIEWER_VERSION" + npm publish --dry-run --access public + else + npm publish --access public + echo "📦 Published @pascal-app/viewer@$VIEWER_VERSION" + fi + + - name: Publish editor + if: inputs.package == 'editor' || inputs.package == 'all' + working-directory: packages/editor + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ "${{ inputs.dry-run }}" = "true" ]; then + echo "🏜️ Dry run — would publish @pascal-app/editor@$EDITOR_VERSION" + npm publish --dry-run --access public + else + npm publish --access public + echo "📦 Published @pascal-app/editor@$EDITOR_VERSION" + fi + + - name: Build & publish mcp + if: inputs.package == 'mcp' || inputs.package == 'all' + working-directory: packages/mcp + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + bun run build + if [ "${{ inputs.dry-run }}" = "true" ]; then + echo "🏜️ Dry run — would publish @pascal-app/mcp@$MCP_VERSION" + npm publish --dry-run --access public + else + npm publish --access public + echo "📦 Published @pascal-app/mcp@$MCP_VERSION" + fi + + - name: Build & publish ifc-converter + if: inputs.package == 'ifc-converter' || inputs.package == 'all' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + # ifc-converter depends on @pascal-app/core (workspace) — build it first + bun run build --filter @pascal-app/core 2>/dev/null || (cd packages/core && bun run build) + cd packages/ifc-converter + bun run build + if [ "${{ inputs.dry-run }}" = "true" ]; then + echo "🏜️ Dry run — would publish @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION" + npm publish --dry-run --access public + else + npm publish --access public + echo "📦 Published @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION" + fi + + - name: Commit version bumps & tag + if: inputs.dry-run == false + run: | + git add -A + PKGS="" + TAGS="" + + if [ -n "$CORE_VERSION" ]; then + PKGS="$PKGS @pascal-app/core@$CORE_VERSION" + TAGS="$TAGS @pascal-app/core@$CORE_VERSION" + fi + if [ -n "$VIEWER_VERSION" ]; then + PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION" + TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION" + fi + if [ -n "$EDITOR_VERSION" ]; then + PKGS="$PKGS @pascal-app/editor@$EDITOR_VERSION" + TAGS="$TAGS @pascal-app/editor@$EDITOR_VERSION" + fi + if [ -n "$MCP_VERSION" ]; then + PKGS="$PKGS @pascal-app/mcp@$MCP_VERSION" + TAGS="$TAGS @pascal-app/mcp@$MCP_VERSION" + fi + if [ -n "$IFC_CONVERTER_VERSION" ]; then + PKGS="$PKGS @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION" + TAGS="$TAGS @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION" + fi + + git commit -m "release:${PKGS}" + + for TAG in $TAGS; do + git tag "$TAG" + done + + git push --follow-tags diff --git a/services/pascal-editor/.gitignore b/services/pascal-editor/.gitignore new file mode 100644 index 000000000..2ef6592f0 --- /dev/null +++ b/services/pascal-editor/.gitignore @@ -0,0 +1,56 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +package-lock.json +.pnp +.pnp.js + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production +.env.production.local + +# Testing +coverage + +# Turbo +.turbo + +# Supabase local state +supabase/.branches/ +supabase/.temp/ + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist +*.tsbuildinfo + + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem +/.playwright-mcp +og-test +.env*.local + +# Worktrees +.worktrees + +# Local IDE/agent configs +.claude/launch.json +.claude/settings.local.json +.vscode/launch.json diff --git a/services/pascal-editor/.npmrc b/services/pascal-editor/.npmrc new file mode 100644 index 000000000..e69de29bb diff --git a/services/pascal-editor/.vscode/settings.json b/services/pascal-editor/.vscode/settings.json new file mode 100644 index 000000000..8b596d230 --- /dev/null +++ b/services/pascal-editor/.vscode/settings.json @@ -0,0 +1,41 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "biome.configPath": "./biome.jsonc", + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": false, + "editor.formatOnPaste": true, + "emmet.showExpandedAbbreviation": "never", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "always", + "quickfix.biome": "always", + "source.organizeImports.biome": "always", + "source.organizeImports": "never" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnPaste": false + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "css.lint.unknownAtRules": "ignore", + "[graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "evenBetterToml.formatter.allowedBlankLines": 4 +} diff --git a/services/pascal-editor/AGENTS.md b/services/pascal-editor/AGENTS.md new file mode 100644 index 000000000..9ddb52fb3 --- /dev/null +++ b/services/pascal-editor/AGENTS.md @@ -0,0 +1,52 @@ +# Agent Instructions — `pascalorg/editor` + +Public, open-source home of `@pascal-app/{core,viewer,editor,mcp}` and the standalone editor app. Consumed both as npm packages and (in `pascalorg/private-editor`) as a git submodule. + +## Repo Shape + +| Path | Purpose | +|---|---| +| `packages/core` | Scene graph, node schemas, stores, event bus, core systems — pure logic, no Three.js | +| `packages/viewer` | Standalone 3D canvas: renderers, viewer systems, presentation state | +| `packages/editor` | Editor UI components reused by the standalone app and embedders | +| `packages/mcp` | MCP server and scene storage adapters | +| `apps/editor` | Standalone editor app — composes `viewer` + `editor` + tools | + +## Where to look + +- **Architecture rules** — `wiki/architecture/` (read on demand; index in `wiki/architecture/README.md`). +- **Skills (ready workflows)** — `.agents/skills//SKILL.md`. Same content is reachable as `.claude/skills/`, `.cursor/skills/`, `.codex/skills/` (symlinks to `.agents/skills/`). +- **Repo orientation for humans** — `README.md`, `SETUP.md`, `CONTRIBUTING.md`. + +`CLAUDE.md`, `GEMINI.md`, and `.github/copilot-instructions.md` are symlinks to this file. Codex reads this file directly. + +## Layer Boundaries (read once, internalise) + +- **`packages/core`** owns domain data and pure logic. It must not import Three.js, `packages/viewer`, `apps/editor`, rendering/UI concepts, tools, modes, phases, or view-specific concepts such as floorplan or paint preview. +- **`packages/viewer`** owns the standalone 3D canvas, renderers, viewer systems, and genuine presentation state. It must not know about `useEditor`, editor tools, phases, modes, paint mode, floorplan state, or editor-only presentation vocabulary. +- **`apps/editor`** owns the editing experience: tools, `useEditor`, panels, floorplan helpers, paint mode, keyboard shortcuts, command palette, action menus, cursor badges, and editor-only overlays. Editor features are injected into `` via props and children. + +Details, examples, and rationale live in `wiki/architecture/layers.md`, `wiki/architecture/viewer-isolation.md`, `wiki/architecture/systems.md`, `wiki/architecture/renderers.md`, `wiki/architecture/tools.md`. + +## When making architecture-sensitive changes + +Read the relevant page in `wiki/architecture/` **before** writing code. The page list lives in `wiki/architecture/README.md`. As a minimum: + +- Adding a node type → `node-schemas.md`, `renderers.md`, `systems.md` +- Adding a tool → `tools.md`, `spatial-queries.md`, `events.md` +- Adding / changing a placement or move interaction → `tools.md` ("2D ↔ 3D behavioral parity": applicable behaviors must exist in both views; port the change to the sibling 2D/3D file in the same PR) +- Adding a system → `systems.md`, `scene-registry.md` +- Anything in `packages/viewer` → `viewer-isolation.md`, `layers.md` +- Anything touching selection → `selection-managers.md`, `scene-registry.md`, `events.md` + +## When reviewing a PR + +Invoke the `review-architecture` skill (`.agents/skills/review-architecture/SKILL.md`). It loads the required architecture pages, fetches the diff, classifies each new file by layer, and reports findings grouped by severity. + +## Operating rules + +- Read the full file before editing. Plan all changes, then make one complete edit. +- When the user corrects you, stop and re-read their message. +- After two consecutive tool failures, stop and change approach. +- Don't introduce backwards-compatibility shims, dead code, or speculative abstractions. +- Don't write new comments unless they explain a non-obvious *why*. diff --git a/services/pascal-editor/CHANGELOG.md b/services/pascal-editor/CHANGELOG.md new file mode 100644 index 000000000..42b0a6fea --- /dev/null +++ b/services/pascal-editor/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +## 0.6.0 (2026-04-21) + +### Features + +- **Multi-surface material system** — per-surface materials for walls, stairs, roofs with click-targeted 3D editing ([#266](https://github.com/pascalorg/editor/pull/266)) by [@sudhir9297](https://github.com/sudhir9297) +- **Automatic wall-room generation** — closed wall loops auto-split and generate slabs ([#255](https://github.com/pascalorg/editor/pull/255), [#257](https://github.com/pascalorg/editor/pull/257)) by [@sudhir9297](https://github.com/sudhir9297) +- **Stair-slab integration** — stair-driven cutouts in slabs and ceilings, auto ceilings from wall loops +- **Curved fence support** + endpoint move tools ([#267](https://github.com/pascalorg/editor/pull/267)) by [@sudhir9297](https://github.com/sudhir9297) +- **13 material presets** — granite, marble, parquet, wallpaper, wood and more ([#231](https://github.com/pascalorg/editor/pull/231)) by [@sudhir9297](https://github.com/sudhir9297) +- **Export scene system** — GLB, STL, OBJ formats ([#203](https://github.com/pascalorg/editor/pull/203)) by [@zephran-dev](https://github.com/zephran-dev), with STL/OBJ groundwork by [@mvanhorn](https://github.com/mvanhorn) ([#175](https://github.com/pascalorg/editor/pull/175)) +- **Street view / walkthrough mode** ([#173](https://github.com/pascalorg/editor/pull/173)) by [@Yashism](https://github.com/Yashism) +- **Duplicate project** ([#178](https://github.com/pascalorg/editor/pull/178)) by [@kleenkanteen](https://github.com/kleenkanteen) +- **Editable wall length slider** ([#195](https://github.com/pascalorg/editor/pull/195)) by [@zephran-dev](https://github.com/zephran-dev) +- **Infinity dragging slider** using PointerLock API ([#206](https://github.com/pascalorg/editor/pull/206)) by [@claygeo](https://github.com/claygeo) +- **Material system enhancements** ([#201](https://github.com/pascalorg/editor/pull/201)) by [@PMAT77](https://github.com/PMAT77) +- **Editor layout redesign v2** + 3D box select +- **Move/rotate building** + relative positioning for all tools +- **Grid snap toolbar controls** +- **Cut-out button** in floating action menu for slabs and ceilings + +### Fixes + +- **WebGPU renderer** — await `renderer.init()` in Canvas GL factory ([#233](https://github.com/pascalorg/editor/pull/233)) by [@b9llach](https://github.com/b9llach) +- **WebGPU fallback** — skip post-processing when unavailable ([#234](https://github.com/pascalorg/editor/pull/234)) by [@b9llach](https://github.com/b9llach) +- **Crash on mode switch** — fix crash when switching to Furniture mode ([#237](https://github.com/pascalorg/editor/pull/237)) by [@txhno](https://github.com/txhno) +- **Crash on duplicate** — prevent crash when duplicating elements ([#239](https://github.com/pascalorg/editor/pull/239)) by [@nnhhoang](https://github.com/nnhhoang) +- **Delete walls/slabs** via floating action menu ([#180](https://github.com/pascalorg/editor/pull/180)) by [@nnhhoang](https://github.com/nnhhoang) +- **Counter-clockwise rotation** — T key for CCW rotation on selected nodes ([#184](https://github.com/pascalorg/editor/pull/184)) by [@nnhhoang](https://github.com/nnhhoang) +- **Scene singleton cleanup** — release singletons on Editor unmount ([#214](https://github.com/pascalorg/editor/pull/214)) by [@geopenta](https://github.com/geopenta) +- **State management & memory leaks** ([#152](https://github.com/pascalorg/editor/pull/152)) by [@hobostay](https://github.com/hobostay) +- **Ghost wall prevention** — use WALL_MIN_LENGTH constant ([#168](https://github.com/pascalorg/editor/pull/168)) by [@zephran-dev](https://github.com/zephran-dev) +- **Catalog image optimization** — add sizes and loading props ([#189](https://github.com/pascalorg/editor/pull/189)) by [@korvixhq](https://github.com/korvixhq) +- **Code cleanup** — remove unused `@ts-expect-error` directive ([#150](https://github.com/pascalorg/editor/pull/150)) by [@cs68614-hash](https://github.com/cs68614-hash) +- Robust undo/redo with nested history pause/resume +- Post-processing recovery after duplicate scene mutations +- Improved snapping across all geometry types +- Thumbnails, placement, and responsiveness improvements +- Stair elevation sync with floor slabs + +### Contributors + +A huge thank you to everyone who contributed to this release! 🎉 + +- [@sudhir9297](https://github.com/sudhir9297) — material system, wall-room generation, curved walls, stairs, fences (7 PRs!) +- [@zephran-dev](https://github.com/zephran-dev) — export system, wall length slider, ghost wall fix +- [@nnhhoang](https://github.com/nnhhoang) — rotation controls, delete actions, crash fix +- [@b9llach](https://github.com/b9llach) — WebGPU renderer fixes +- [@txhno](https://github.com/txhno) — furniture mode crash fix +- [@Yashism](https://github.com/Yashism) — street view / walkthrough mode +- [@claygeo](https://github.com/claygeo) — infinity dragging slider +- [@geopenta](https://github.com/geopenta) — scene singleton cleanup +- [@kleenkanteen](https://github.com/kleenkanteen) — duplicate project feature +- [@mvanhorn](https://github.com/mvanhorn) — STL/OBJ export formats +- [@PMAT77](https://github.com/PMAT77) — material system enhancements +- [@korvixhq](https://github.com/korvixhq) — catalog image optimization +- [@hobostay](https://github.com/hobostay) — state management & memory leak fixes +- [@cs68614-hash](https://github.com/cs68614-hash) — code cleanup diff --git a/services/pascal-editor/CLAUDE.md b/services/pascal-editor/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/services/pascal-editor/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/services/pascal-editor/CONTRIBUTING.md b/services/pascal-editor/CONTRIBUTING.md new file mode 100644 index 000000000..89866a7e1 --- /dev/null +++ b/services/pascal-editor/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to Pascal Editor + +Thanks for your interest in contributing! We welcome all kinds of contributions — bug fixes, new features, documentation, and ideas. + +## Getting started + +### Prerequisites + +- [Bun](https://bun.sh/) 1.3+ (or Node.js 18+) + +### Setup + +```bash +git clone https://github.com/pascalorg/editor.git +cd editor +bun install +bun dev +``` + +The editor will be running at **http://localhost:3000**. That's it! + +### Optional + +Copy `.env.example` to `.env` and add a Google Maps API key if you want address search functionality. The editor works fully without it. + +## Making changes + +### Code style + +We use [Biome](https://biomejs.dev/) for linting and formatting. Before submitting a PR: + +```bash +bun check # Check for issues +bun check:fix # Auto-fix issues +``` + +### Project structure + +| Package | What it does | +|---------|-------------| +| `packages/core` | Scene schema, state management, systems — no UI | +| `packages/viewer` | 3D rendering with React Three Fiber | +| `apps/editor` | The full editor app (Next.js) | + +A key rule: **`packages/viewer` must never import from `apps/editor`**. The viewer is a standalone component; editor-specific behavior is injected via props/children. + +### Building a plugin + +New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md) for the contract, and copy [`packages/plugin-trees`](packages/plugin-trees) as a worked example. + +## Submitting a PR + +1. **Fork the repo** and create a branch from `main` +2. **Make your changes** and test locally with `bun dev` +3. **Run `bun check`** to make sure linting passes +4. **Open a PR** with a clear description of what changed and why +5. **Link related issues** if applicable (e.g., "Fixes #42") + +### PR tips + +- Keep PRs focused — one feature or fix per PR +- Include screenshots or recordings for visual changes +- If you're unsure about an approach, open an issue or discussion first + +## Reporting bugs + +Use the [Bug Report](https://github.com/pascalorg/editor/issues/new?template=bug_report.yml) template. Include steps to reproduce — this helps us fix things faster. + +## Suggesting features + +Use the [Feature Request](https://github.com/pascalorg/editor/issues/new?template=feature_request.yml) template, or start a [Discussion](https://github.com/pascalorg/editor/discussions) if you want to brainstorm first. + +## Questions? + +Head to [Discussions](https://github.com/pascalorg/editor/discussions) — we're happy to help! diff --git a/services/pascal-editor/GEMINI.md b/services/pascal-editor/GEMINI.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/services/pascal-editor/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/services/pascal-editor/LICENSE b/services/pascal-editor/LICENSE new file mode 100644 index 000000000..083fd9e32 --- /dev/null +++ b/services/pascal-editor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pascal Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/services/pascal-editor/README.md b/services/pascal-editor/README.md new file mode 100644 index 000000000..3ba4f6f24 --- /dev/null +++ b/services/pascal-editor/README.md @@ -0,0 +1,429 @@ +# Pascal Editor + +A 3D building editor built with React Three Fiber and WebGPU. + +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![npm @pascal-app/core](https://img.shields.io/npm/v/@pascal-app/core?label=%40pascal-app%2Fcore)](https://www.npmjs.com/package/@pascal-app/core) +[![npm @pascal-app/viewer](https://img.shields.io/npm/v/@pascal-app/viewer?label=%40pascal-app%2Fviewer)](https://www.npmjs.com/package/@pascal-app/viewer) +[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/SaBRA9t2) +[![X (Twitter)](https://img.shields.io/badge/follow-%40pascal__app-black?logo=x&logoColor=white)](https://x.com/pascal_app) + +https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b + + + +## Repository Architecture + +This is a Turborepo monorepo with three main packages: + +``` +editor/ +├── apps/ +│ └── editor/ # Next.js application +├── packages/ +│ ├── core/ # Schema definitions, state management, systems +│ ├── viewer/ # 3D rendering components +│ └── ui/ # Shared UI components +``` + +### Separation of Concerns + +| Package | Responsibility | +|---------|---------------| +| **@pascal-app/core** | Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus | +| **@pascal-app/viewer** | 3D rendering via React Three Fiber, default camera/controls, post-processing | +| **apps/editor** | UI components, tools, custom behaviors, editor-specific systems | + +The **viewer** renders the scene with sensible defaults. The **editor** extends it with interactive tools, selection management, and editing capabilities. + +### Stores + +Each package has its own Zustand store for managing state: + +| Store | Package | Responsibility | +|-------|---------|----------------| +| `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. | +| `useViewer` | `@pascal-app/viewer` | Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. | +| `useEditor` | `apps/editor` | Editor state: active tool, structure layer visibility, panel states, editor-specific preferences. | + +**Access patterns:** + +```typescript +// Subscribe to state changes (React component) +const nodes = useScene((state) => state.nodes) +const levelId = useViewer((state) => state.selection.levelId) +const activeTool = useEditor((state) => state.tool) + +// Access state outside React (callbacks, systems) +const node = useScene.getState().nodes[id] +useViewer.getState().setSelection({ levelId: 'level_123' }) +``` + +--- + +## Core Concepts + +### Nodes + +Nodes are the data primitives that describe the 3D scene. All nodes extend `BaseNode`: + +```typescript +BaseNode { + id: string // Auto-generated with type prefix (e.g., "wall_abc123") + type: string // Discriminator for type-safe handling + parentId: string | null // Parent node reference + visible: boolean + camera?: Camera // Optional saved camera position + metadata?: JSON // Arbitrary metadata (e.g., { isTransient: true }) +} +``` + +**Node Hierarchy:** + +``` +Site +└── Building + └── Level + ├── Wall → Item (doors, windows) + ├── Slab + ├── Ceiling → Item (lights) + ├── Roof + ├── Zone + ├── Scan (3D reference) + └── Guide (2D reference) +``` + +Nodes are stored in a **flat dictionary** (`Record`), not a nested tree. Parent-child relationships are defined via `parentId` and `children` arrays. + +--- + +### Scene State (Zustand Store) + +The scene is managed by a Zustand store in `@pascal-app/core`: + +```typescript +useScene.getState() = { + nodes: Record, // All nodes + rootNodeIds: string[], // Top-level nodes (sites) + dirtyNodes: Set, // Nodes pending system updates + + createNode(node, parentId), + updateNode(id, updates), + deleteNode(id), +} +``` + +**Middleware:** +- **Persist** - Saves to IndexedDB (excludes transient nodes) +- **Temporal** (Zundo) - Undo/redo with 50-step history + +--- + +### Scene Registry + +The registry maps node IDs to their Three.js objects for fast lookup: + +```typescript +sceneRegistry = { + nodes: Map, // ID → 3D object + byType: { + wall: Set, + item: Set, + zone: Set, + // ... + } +} +``` + +Renderers register their refs using the `useRegistry` hook: + +```tsx +const ref = useRef(null!) +useRegistry(node.id, 'wall', ref) +``` + +This allows systems to access 3D objects directly without traversing the scene graph. + +--- + +### Node Renderers + +Renderers are React components that create Three.js objects for each node type: + +``` +SceneRenderer +└── NodeRenderer (dispatches by type) + ├── BuildingRenderer + ├── LevelRenderer + ├── WallRenderer + ├── SlabRenderer + ├── ZoneRenderer + ├── ItemRenderer + └── ... +``` + +**Pattern:** +1. Renderer creates a placeholder mesh/group +2. Registers it with `useRegistry` +3. Systems update geometry based on node data + +Example (simplified): +```tsx +const WallRenderer = ({ node }) => { + const ref = useRef(null!) + useRegistry(node.id, 'wall', ref) + + return ( + + {/* Replaced by WallSystem */} + + {node.children.map(id => )} + + ) +} +``` + +--- + +### Systems + +Systems are React components that run in the render loop (`useFrame`) to update geometry and transforms. They process **dirty nodes** marked by the store. + +**Core Systems (in `@pascal-app/core`):** + +| System | Responsibility | +|--------|---------------| +| `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows | +| `SlabSystem` | Generates floor geometry from polygons | +| `CeilingSystem` | Generates ceiling geometry | +| `RoofSystem` | Generates roof geometry | +| `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) | + +**Viewer Systems (in `@pascal-app/viewer`):** + +| System | Responsibility | +|--------|---------------| +| `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo modes) | +| `ScanSystem` | Controls 3D scan visibility | +| `GuideSystem` | Controls guide image visibility | + +**Processing Pattern:** +```typescript +useFrame(() => { + for (const id of dirtyNodes) { + const obj = sceneRegistry.nodes.get(id) + const node = useScene.getState().nodes[id] + + // Update geometry, transforms, etc. + updateGeometry(obj, node) + + dirtyNodes.delete(id) + } +}) +``` + +--- + +### Dirty Nodes + +When a node changes, it's marked as **dirty** in `useScene.getState().dirtyNodes`. Systems check this set each frame and only recompute geometry for dirty nodes. + +```typescript +// Automatic: createNode, updateNode, deleteNode mark nodes dirty +useScene.getState().updateNode(wallId, { thickness: 0.2 }) +// → wallId added to dirtyNodes +// → WallSystem regenerates geometry next frame +// → wallId removed from dirtyNodes +``` + +**Manual marking:** +```typescript +useScene.getState().dirtyNodes.add(wallId) +``` + +--- + +### Event Bus + +Inter-component communication uses a typed event emitter (mitt): + +```typescript +// Node events +emitter.on('wall:click', (event) => { ... }) +emitter.on('item:enter', (event) => { ... }) +emitter.on('zone:context-menu', (event) => { ... }) + +// Grid events (background) +emitter.on('grid:click', (event) => { ... }) + +// Event payload +NodeEvent { + node: AnyNode + position: [x, y, z] + localPosition: [x, y, z] + normal?: [x, y, z] + stopPropagation: () => void +} +``` + +--- + +### Spatial Grid Manager + +Handles collision detection and placement validation: + +```typescript +spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation) +spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions) +spatialGridManager.getSlabElevationAt(levelId, x, z) +``` + +Used by item placement tools to validate positions and calculate slab elevations. + +--- + +## Editor Architecture + +The editor extends the viewer with: + +### Tools + +Tools are activated via the toolbar and handle user input for specific operations: + +- **SelectTool** - Selection and manipulation +- **WallTool** - Draw walls +- **ZoneTool** - Create zones +- **ItemTool** - Place furniture/fixtures +- **SlabTool** - Create floor slabs + +### Selection Manager + +The editor uses a custom selection manager with hierarchical navigation: + +``` +Site → Building → Level → Zone → Items +``` + +Each depth level has its own selection strategy for hover/click behavior. + +### Editor-Specific Systems + +- `ZoneSystem` - Controls zone visibility based on level mode +- Custom camera controls with node focusing + +--- + +## Data Flow + +``` +User Action (click, drag) + ↓ +Tool Handler + ↓ +useScene.createNode() / updateNode() + ↓ +Node added/updated in store +Node marked dirty + ↓ +React re-renders NodeRenderer +useRegistry() registers 3D object + ↓ +System detects dirty node (useFrame) +Updates geometry via sceneRegistry +Clears dirty flag +``` + +--- + +## Building a Plugin + +The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same `Plugin` manifest the built-ins use — there is no separate internal API. + +- **Contract reference** — [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md): the `Plugin` shape, panel contributions, discovery (`setPluginDiscovery`), lifecycle, and what's in/out of v1. +- **Worked example** — [`packages/plugin-trees`](packages/plugin-trees): a first-party plugin (procedural trees, flowers, grass + a presets panel) structurally identical to a third-party pack. Copy it as a starting point. + +--- + +## Technology Stack + +- **React 19** + **Next.js 16** +- **Three.js** (WebGPU renderer) +- **React Three Fiber** + **Drei** +- **Zustand** (state management) +- **Zod** (schema validation) +- **Zundo** (undo/redo) +- **three-bvh-csg** (Boolean geometry operations) +- **Turborepo** (monorepo management) +- **Bun** (package manager) + +--- + +## Getting Started + +### Development + +Run the development server from the **root directory** to enable hot reload for all packages: + +```bash +# Install dependencies +bun install + +# Run development server (builds packages + starts editor with watch mode) +bun dev + +# This will: +# 1. Build @pascal-app/core and @pascal-app/viewer +# 2. Start watching both packages for changes +# 3. Start the Next.js editor dev server +# Open http://localhost:3000 +``` + +**Important:** Always run `bun dev` from the root directory to ensure the package watchers are running. This enables hot reload when you edit files in `packages/core/src/` or `packages/viewer/src/`. + +### Building for Production + +```bash +# Build all packages +turbo build + +# Build specific package +turbo build --filter=@pascal-app/core +``` + +### Publishing Packages + +```bash +# Build packages +turbo build --filter=@pascal-app/core --filter=@pascal-app/viewer + +# Publish to npm +npm publish --workspace=@pascal-app/core --access public +npm publish --workspace=@pascal-app/viewer --access public +``` + +--- + +## Key Files + +| Path | Description | +|------|-------------| +| `packages/core/src/schema/` | Node type definitions (Zod schemas) | +| `packages/core/src/store/use-scene.ts` | Scene state store | +| `packages/core/src/hooks/scene-registry/` | 3D object registry | +| `packages/core/src/systems/` | Geometry generation systems | +| `packages/viewer/src/components/renderers/` | Node renderers | +| `packages/viewer/src/components/viewer/` | Main Viewer component | +| `apps/editor/components/tools/` | Editor tools | +| `apps/editor/store/` | Editor-specific state | + +--- + +## Contributors + +Aymeric Rabot +Wassim Samad +Sudhir + +--- + +pascalorg/editor | Trendshift diff --git a/services/pascal-editor/SETUP.md b/services/pascal-editor/SETUP.md new file mode 100644 index 000000000..551069acc --- /dev/null +++ b/services/pascal-editor/SETUP.md @@ -0,0 +1,97 @@ +# Pascal Editor — Setup + +## Prerequisites + +- [Bun](https://bun.sh/) 1.3+ (recommended), or Node.js 22+ + +## Quick Start + +```bash +bun install +bun dev +``` + +The editor will be running at **http://localhost:3000**. + +## Environment Variables (optional) + +Copy `.env.example` to `.env` if you need: + +```bash +cp .env.example .env +``` + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` | No | Enables address search in the editor | +| `PORT` | No | Dev server port (default: 3000) | + +## AI Design Agent + +Saved scenes expose an `AI Designer` sidebar. Configure an OpenAI-compatible +model endpoint before using it: + +```env +PASCAL_AGENT_API_KEY=your_api_key +PASCAL_AGENT_BASE_URL=https://api.openai.com/v1 +PASCAL_AGENT_MODEL=gpt-4.1-mini +PASCAL_DATA_DIR=/absolute/path/to/pascal-data +PASCAL_AGENT_PROXY_SECRET=replace_with_a_long_random_value +``` + +The editor and Agent MCP processes must use the same `PASCAL_DATA_DIR` or +`PASCAL_DB_PATH`. Each chat request launches an isolated `pascal-mcp` stdio +process, binds it to the current scene, and closes it when streaming finishes. +This prevents concurrent users from sharing the MCP bridge's in-memory scene. + +The default safety limits are 18 tool steps, 90 seconds per tool, and 5 minutes +per turn. Override them with `PASCAL_AGENT_MAX_STEPS`, +`PASCAL_AGENT_STEP_TIMEOUT_MS`, and `PASCAL_AGENT_TOTAL_TIMEOUT_MS`. + +When the application runs under Node rather than Bun, make Bun available on +`PATH` or set `PASCAL_MCP_COMMAND` to its absolute path. Set +`PASCAL_MCP_ENTRY` when the built MCP executable is outside the monorepo. + +Production requests must pass through a trusted reverse proxy that authenticates +the user, strips any incoming `X-Pascal-Agent-Proxy-Secret` header, and injects +the configured value before forwarding to Pascal. Bind the Pascal service to a +private interface so users cannot bypass the proxy. + +For a same-domain hosted deployment, set +`NEXT_PUBLIC_PASCAL_BASE_PATH=/pascal-service` before building and +`PASCAL_REQUIRE_HOST_USER=true` at build and runtime. The reverse proxy must +also strip and inject `X-Pascal-User-Id`. New scenes persist that value as +`ownerId`; list, read, mutation, event-stream, and Agent routes reject scenes +owned by another user. + +The standalone app scripts force Next.js to run under Bun because scene +persistence uses built-in SQLite. Starting the app with Node 20 or earlier will +fail when a scene is first loaded or saved. + +The core editor works without environment variables; the AI agent remains disabled until configured. + +## Monorepo Structure + +``` +├── apps/ +│ └── editor/ # Next.js editor application +├── packages/ +│ ├── core/ # @pascal-app/core — Scene schema, state, systems +│ ├── viewer/ # @pascal-app/viewer — 3D rendering +│ └── ui/ # Shared UI components +└── tooling/ # Build & release tooling +``` + +## Scripts + +| Command | Description | +|---------|-------------| +| `bun dev` | Start the development server | +| `bun build` | Build all packages | +| `bun check` | Lint and format check (Biome) | +| `bun check:fix` | Auto-fix lint and format issues | +| `bun check-types` | TypeScript type checking | + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines on submitting PRs and reporting issues. diff --git a/services/pascal-editor/apps/editor/.gitignore b/services/pascal-editor/apps/editor/.gitignore new file mode 100644 index 000000000..684fd2301 --- /dev/null +++ b/services/pascal-editor/apps/editor/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env*.local diff --git a/services/pascal-editor/apps/editor/README.md b/services/pascal-editor/apps/editor/README.md new file mode 100644 index 000000000..ae7e0f624 --- /dev/null +++ b/services/pascal-editor/apps/editor/README.md @@ -0,0 +1,364 @@ +# Pascal Editor + +A 3D building editor built with React Three Fiber and WebGPU. + +## Repository Architecture + +This is a Turborepo monorepo with three main packages: + +``` +editor/ +├── apps/ +│ └── editor/ # Next.js application (this package) +├── packages/ +│ ├── core/ # Schema definitions, state management, systems +│ └── viewer/ # 3D rendering components +``` + +### Separation of Concerns + +| Package | Responsibility | +|---------|---------------| +| **@pascal-app/core** | Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus | +| **@pascal-app/viewer** | 3D rendering via React Three Fiber, default camera/controls, post-processing | +| **apps/editor** | UI components, tools, custom behaviors, editor-specific systems | + +The **viewer** renders the scene with sensible defaults. The **editor** extends it with interactive tools, selection management, and editing capabilities. + +### Stores + +Each package has its own Zustand store for managing state: + +| Store | Package | Responsibility | +|-------|---------|----------------| +| `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. | +| `useViewer` | `@pascal-app/viewer` | Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. | +| `useEditor` | `apps/editor` | Editor state: active tool, structure layer visibility, panel states, editor-specific preferences. | + +**Access patterns:** + +```typescript +// Subscribe to state changes (React component) +const nodes = useScene((state) => state.nodes) +const levelId = useViewer((state) => state.selection.levelId) +const activeTool = useEditor((state) => state.tool) + +// Access state outside React (callbacks, systems) +const node = useScene.getState().nodes[id] +useViewer.getState().setSelection({ levelId: 'level_123' }) +``` + +--- + +## Core Concepts + +### Nodes + +Nodes are the data primitives that describe the 3D scene. All nodes extend `BaseNode`: + +```typescript +BaseNode { + id: string // Auto-generated with type prefix (e.g., "wall_abc123") + type: string // Discriminator for type-safe handling + parentId: string | null // Parent node reference + visible: boolean + camera?: Camera // Optional saved camera position + metadata?: JSON // Arbitrary metadata (e.g., { isTransient: true }) +} +``` + +**Node Hierarchy:** + +``` +Site +└── Building + └── Level + ├── Wall → Item (doors, windows) + ├── Slab + ├── Ceiling → Item (lights) + ├── Roof + ├── Zone + ├── Scan (3D reference) + └── Guide (2D reference) +``` + +Nodes are stored in a **flat dictionary** (`Record`), not a nested tree. Parent-child relationships are defined via `parentId` and `children` arrays. + +--- + +### Scene State (Zustand Store) + +The scene is managed by a Zustand store in `@pascal-app/core`: + +```typescript +useScene.getState() = { + nodes: Record, // All nodes + rootNodeIds: string[], // Top-level nodes (sites) + dirtyNodes: Set, // Nodes pending system updates + + createNode(node, parentId), + updateNode(id, updates), + deleteNode(id), +} +``` + +**Middleware:** +- **Persist** - Saves to IndexedDB (excludes transient nodes) +- **Temporal** (Zundo) - Undo/redo with 50-step history + +--- + +### Scene Registry + +The registry maps node IDs to their Three.js objects for fast lookup: + +```typescript +sceneRegistry = { + nodes: Map, // ID → 3D object + byType: { + wall: Set, + item: Set, + zone: Set, + // ... + } +} +``` + +Renderers register their refs using the `useRegistry` hook: + +```tsx +const ref = useRef(null!) +useRegistry(node.id, 'wall', ref) +``` + +This allows systems to access 3D objects directly without traversing the scene graph. + +--- + +### Node Renderers + +Renderers are React components that create Three.js objects for each node type: + +``` +SceneRenderer +└── NodeRenderer (dispatches by type) + ├── BuildingRenderer + ├── LevelRenderer + ├── WallRenderer + ├── SlabRenderer + ├── ZoneRenderer + ├── ItemRenderer + └── ... +``` + +**Pattern:** +1. Renderer creates a placeholder mesh/group +2. Registers it with `useRegistry` +3. Systems update geometry based on node data + +Example (simplified): +```tsx +const WallRenderer = ({ node }) => { + const ref = useRef(null!) + useRegistry(node.id, 'wall', ref) + + return ( + + {/* Replaced by WallSystem */} + + {node.children.map(id => )} + + ) +} +``` + +--- + +### Systems + +Systems are React components that run in the render loop (`useFrame`) to update geometry and transforms. They process **dirty nodes** marked by the store. + +**Core Systems (in `@pascal-app/core`):** + +| System | Responsibility | +|--------|---------------| +| `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows | +| `SlabSystem` | Generates floor geometry from polygons | +| `CeilingSystem` | Generates ceiling geometry | +| `RoofSystem` | Generates roof geometry | +| `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) | + +**Viewer Systems (in `@pascal-app/viewer`):** + +| System | Responsibility | +|--------|---------------| +| `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo modes) | +| `ScanSystem` | Controls 3D scan visibility | +| `GuideSystem` | Controls guide image visibility | + +**Processing Pattern:** +```typescript +useFrame(() => { + for (const id of dirtyNodes) { + const obj = sceneRegistry.nodes.get(id) + const node = useScene.getState().nodes[id] + + // Update geometry, transforms, etc. + updateGeometry(obj, node) + + dirtyNodes.delete(id) + } +}) +``` + +--- + +### Dirty Nodes + +When a node changes, it's marked as **dirty** in `useScene.getState().dirtyNodes`. Systems check this set each frame and only recompute geometry for dirty nodes. + +```typescript +// Automatic: createNode, updateNode, deleteNode mark nodes dirty +useScene.getState().updateNode(wallId, { thickness: 0.2 }) +// → wallId added to dirtyNodes +// → WallSystem regenerates geometry next frame +// → wallId removed from dirtyNodes +``` + +**Manual marking:** +```typescript +useScene.getState().dirtyNodes.add(wallId) +``` + +--- + +### Event Bus + +Inter-component communication uses a typed event emitter (mitt): + +```typescript +// Node events +emitter.on('wall:click', (event) => { ... }) +emitter.on('item:enter', (event) => { ... }) +emitter.on('zone:context-menu', (event) => { ... }) + +// Grid events (background) +emitter.on('grid:click', (event) => { ... }) + +// Event payload +NodeEvent { + node: AnyNode + position: [x, y, z] + localPosition: [x, y, z] + normal?: [x, y, z] + stopPropagation: () => void +} +``` + +--- + +### Spatial Grid Manager + +Handles collision detection and placement validation: + +```typescript +spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation) +spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions) +spatialGridManager.getSlabElevationAt(levelId, x, z) +``` + +Used by item placement tools to validate positions and calculate slab elevations. + +--- + +## Editor Architecture + +The editor extends the viewer with: + +### Tools + +Tools are activated via the toolbar and handle user input for specific operations: + +- **SelectTool** - Selection and manipulation +- **WallTool** - Draw walls +- **ZoneTool** - Create zones +- **ItemTool** - Place furniture/fixtures +- **SlabTool** - Create floor slabs + +### Selection Manager + +The editor uses a custom selection manager with hierarchical navigation: + +``` +Site → Building → Level → Zone → Items +``` + +Each depth level has its own selection strategy for hover/click behavior. + +### Editor-Specific Systems + +- `ZoneSystem` - Controls zone visibility based on level mode +- Custom camera controls with node focusing + +--- + +## Data Flow + +``` +User Action (click, drag) + ↓ +Tool Handler + ↓ +useScene.createNode() / updateNode() + ↓ +Node added/updated in store +Node marked dirty + ↓ +React re-renders NodeRenderer +useRegistry() registers 3D object + ↓ +System detects dirty node (useFrame) +Updates geometry via sceneRegistry +Clears dirty flag +``` + +--- + +## Technology Stack + +- **React 19** + **Next.js 15** +- **Three.js** (WebGPU renderer) +- **React Three Fiber** + **Drei** +- **Zustand** (state management) +- **Zod** (schema validation) +- **Zundo** (undo/redo) +- **three-bvh-csg** (Boolean geometry operations) + +--- + +## Getting Started + +```bash +# Install dependencies +pnpm install + +# Run development server +pnpm dev + +# Open http://localhost:3000 +``` + +--- + +## Key Files + +| Path | Description | +|------|-------------| +| `packages/core/src/schema/` | Node type definitions (Zod schemas) | +| `packages/core/src/hooks/use-scene.ts` | Scene state store | +| `packages/core/src/hooks/scene-registry/` | 3D object registry | +| `packages/core/src/systems/` | Geometry generation systems | +| `packages/viewer/src/components/renderers/` | Node renderers | +| `packages/viewer/src/components/viewer/` | Main Viewer component | +| `apps/editor/components/tools/` | Editor tools | +| `apps/editor/store/` | Editor-specific state | diff --git a/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts b/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts new file mode 100644 index 000000000..ac74871c8 --- /dev/null +++ b/services/pascal-editor/apps/editor/app/api/agent/chat/route.ts @@ -0,0 +1,161 @@ +import { createOpenAI } from '@ai-sdk/openai' +import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai' +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { validatePascalAgentAccess } from '@/lib/pascal-agent-access' +import { getPascalAgentConfig } from '@/lib/pascal-agent-config' +import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp' +import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt' +import { getPascalHostIdentity } from '@/lib/pascal-host-identity' +import { getSceneOperations } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' +export const maxDuration = 600 +export const runtime = 'nodejs' + +const requestSchema = z.object({ + messages: z.array(z.unknown()).min(1).max(100), + sceneId: z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), +}) + +const AGENT_ERROR_MESSAGE = 'AI design agent is temporarily unavailable. Please try again.' +const SCENE_ERROR_MESSAGE = 'The current scene could not be loaded.' + +function accessErrorResponse(result: ReturnType) { + if (result === 'missing_proxy_secret') { + return NextResponse.json({ error: 'agent_proxy_not_configured' }, { status: 503 }) + } + if (result === 'unauthorized') { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + return null +} + +export function GET(request: Request) { + const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers)) + if (accessError) return accessError + const identity = getPascalHostIdentity(request.headers) + if (!identity.allowed) { + return NextResponse.json({ error: 'host_user_required' }, { status: 401 }) + } + const config = getPascalAgentConfig() + return NextResponse.json({ + configured: config.configured, + maxSteps: config.maxSteps, + model: config.model, + }) +} + +export async function POST(request: Request) { + const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers)) + if (accessError) return accessError + const identity = getPascalHostIdentity(request.headers) + if (!identity.allowed) { + return NextResponse.json({ error: 'host_user_required' }, { status: 401 }) + } + const config = getPascalAgentConfig() + if (!config.configured) { + return NextResponse.json( + { + error: 'agent_not_configured', + message: 'Configure PASCAL_AGENT_API_KEY before using the AI design agent.', + }, + { status: 503 }, + ) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) + } + + const parsed = requestSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const operations = await getSceneOperations() + const storedScene = await operations.loadStoredScene(parsed.data.sceneId) + if (!storedScene || (identity.userId && storedScene.ownerId !== identity.userId)) { + return NextResponse.json( + { error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE }, + { status: 404 }, + ) + } + + const abortSignal = AbortSignal.any([request.signal, AbortSignal.timeout(config.totalTimeoutMs)]) + let session: Awaited> + try { + session = await createPascalAgentMcpSession({ + abortSignal, + sceneId: parsed.data.sceneId, + stepTimeoutMs: config.stepTimeoutMs, + }) + } catch (error) { + console.error('Pascal agent could not load the requested scene.', error) + return NextResponse.json( + { error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE }, + { status: 404 }, + ) + } + const provider = createOpenAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + name: 'pascal-agent', + }) + + let finalized = false + const finalize = async (save: boolean) => { + if (finalized) return + finalized = true + try { + if (save && !abortSignal.aborted) { + await session.saveDraft(abortSignal) + } + } finally { + await session.close() + } + } + + try { + const result = streamText({ + abortSignal, + maxOutputTokens: 1600, + messages: convertToModelMessages(parsed.data.messages as UIMessage[]), + model: provider.chat(config.model), + onAbort: async () => finalize(false), + onError: async () => finalize(false), + onFinish: async () => finalize(true), + stopWhen: stepCountIs(config.maxSteps), + system: buildPascalAgentPrompt({ + sceneId: parsed.data.sceneId, + sceneName: session.sceneName, + }), + temperature: 0.2, + tools: session.tools, + }) + + return result.toUIMessageStreamResponse({ + onError: (error) => { + console.error('Pascal agent stream failed.', error) + return AGENT_ERROR_MESSAGE + }, + }) + } catch (error) { + await finalize(false) + console.error('Pascal agent failed before streaming.', error) + return NextResponse.json( + { error: 'agent_failed', message: AGENT_ERROR_MESSAGE }, + { status: 500 }, + ) + } +} diff --git a/services/pascal-editor/apps/editor/app/api/health/route.ts b/services/pascal-editor/apps/editor/app/api/health/route.ts new file mode 100644 index 000000000..300230ba3 --- /dev/null +++ b/services/pascal-editor/apps/editor/app/api/health/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() }) +} diff --git a/services/pascal-editor/apps/editor/app/api/scenes/[id]/events/route.ts b/services/pascal-editor/apps/editor/app/api/scenes/[id]/events/route.ts new file mode 100644 index 000000000..4bae0e3b9 --- /dev/null +++ b/services/pascal-editor/apps/editor/app/api/scenes/[id]/events/route.ts @@ -0,0 +1,124 @@ +import { getPascalHostIdentity } from '@/lib/pascal-host-identity' +import { + guardSceneApiRequest, + sceneApiJson, + sceneApiPreflight, + withSceneApiHeaders, +} from '@/lib/scene-api-security' +import { getSceneOperations } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +type RouteParams = { params: Promise<{ id: string }> } + +const POLL_MS = 250 +const HEARTBEAT_MS = 15_000 +const MAX_EVENTS_PER_POLL = 50 + +export function OPTIONS(request: Request) { + return sceneApiPreflight(request) +} + +export async function GET(request: Request, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const identity = getPascalHostIdentity(request.headers) + if (!identity.allowed) { + return sceneApiJson(request, { error: 'host_user_required' }, { status: 401 }) + } + + const { id } = await params + const operations = await getSceneOperations() + + if (!operations.canListSceneEvents) { + return sceneApiJson(request, { error: 'scene_events_unavailable' }, { status: 501 }) + } + + const scene = await operations.loadStoredScene(id) + if (!scene) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + if (identity.userId && scene.ownerId !== identity.userId) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + + const url = new URL(request.url) + const afterFromQuery = Number.parseInt(url.searchParams.get('after') ?? '0', 10) + const afterFromHeader = Number.parseInt(request.headers.get('Last-Event-ID') ?? '0', 10) + let cursor = Math.max( + 0, + Number.isFinite(afterFromQuery) ? afterFromQuery : 0, + Number.isFinite(afterFromHeader) ? afterFromHeader : 0, + ) + + const encoder = new TextEncoder() + let closed = false + let pollTimer: ReturnType | undefined + let heartbeatTimer: ReturnType | undefined + + const stream = new ReadableStream({ + start(controller) { + const enqueue = (chunk: string) => { + if (!closed) controller.enqueue(encoder.encode(chunk)) + } + + const close = () => { + if (closed) return + closed = true + if (pollTimer) clearTimeout(pollTimer) + if (heartbeatTimer) clearInterval(heartbeatTimer) + try { + controller.close() + } catch { + // The client may have already closed the stream. + } + } + + request.signal.addEventListener('abort', close, { once: true }) + enqueue('retry: 1000\n\n') + + const poll = async () => { + if (closed) return + try { + const events = await operations.listSceneEvents(id, { + afterEventId: cursor, + limit: MAX_EVENTS_PER_POLL, + }) + for (const event of events) { + cursor = event.eventId + enqueue(`id: ${event.eventId}\n`) + enqueue('event: scene\n') + enqueue(`data: ${JSON.stringify(event)}\n\n`) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + enqueue('event: error\n') + enqueue(`data: ${JSON.stringify({ message })}\n\n`) + } finally { + if (!closed) pollTimer = setTimeout(poll, POLL_MS) + } + } + + heartbeatTimer = setInterval(() => enqueue(': keepalive\n\n'), HEARTBEAT_MS) + void poll() + }, + cancel() { + closed = true + if (pollTimer) clearTimeout(pollTimer) + if (heartbeatTimer) clearInterval(heartbeatTimer) + }, + }) + + return withSceneApiHeaders( + request, + new Response(stream, { + headers: { + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'Content-Type': 'text/event-stream; charset=utf-8', + 'X-Accel-Buffering': 'no', + }, + }), + ) +} diff --git a/services/pascal-editor/apps/editor/app/api/scenes/[id]/route.ts b/services/pascal-editor/apps/editor/app/api/scenes/[id]/route.ts new file mode 100644 index 000000000..faabeb5e7 --- /dev/null +++ b/services/pascal-editor/apps/editor/app/api/scenes/[id]/route.ts @@ -0,0 +1,253 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { apiGraphSchema } from '@/lib/graph-schema' +import { getPascalHostIdentity, type PascalHostIdentity } from '@/lib/pascal-host-identity' +import { + guardSceneApiRequest, + sceneApiJson, + sceneApiPreflight, + withSceneApiHeaders, +} from '@/lib/scene-api-security' +import { getSceneOperations } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ id: string }> } + +const putSceneSchema = z.object({ + name: z.string().min(1).max(200).optional(), + graph: apiGraphSchema, + thumbnailUrl: z.string().url().nullable().optional(), + expectedVersion: z.number().int().nonnegative().optional(), +}) + +const patchSceneSchema = z.object({ + name: z.string().min(1).max(200), + expectedVersion: z.number().int().nonnegative().optional(), +}) + +function hostIdentityError(request: NextRequest, identity: PascalHostIdentity) { + return identity.allowed + ? null + : sceneApiJson(request, { error: 'host_user_required' }, { status: 401 }) +} + +function canAccessScene(identity: PascalHostIdentity, ownerId: string | null): boolean { + return !identity.userId || ownerId === identity.userId +} + +export function OPTIONS(request: NextRequest) { + return sceneApiPreflight(request) +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const identity = getPascalHostIdentity(request.headers) + const identityError = hostIdentityError(request, identity) + if (identityError) return identityError + + const { id } = await params + const operations = await getSceneOperations() + try { + const scene = await operations.loadStoredScene(id) + if (!scene) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + if (!canAccessScene(identity, scene.ownerId)) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + return sceneApiJson(request, scene, { + headers: { ETag: `"${scene.version}"` }, + }) + } catch (error) { + return handleStoreError(request, error) + } +} + +export async function PUT(request: NextRequest, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const identity = getPascalHostIdentity(request.headers) + const identityError = hostIdentityError(request, identity) + if (identityError) return identityError + + const { id } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return sceneApiJson( + request, + { error: 'invalid_request', details: 'body must be valid JSON' }, + { status: 400 }, + ) + } + + const parsed = putSceneSchema.safeParse(body) + if (!parsed.success) { + return sceneApiJson( + request, + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const ifMatch = parseIfMatch(request.headers.get('If-Match')) + const expectedVersion = ifMatch ?? parsed.data.expectedVersion + + const operations = await getSceneOperations() + try { + const existing = await operations.loadStoredScene(id) + if (!existing) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + if (!canAccessScene(identity, existing.ownerId)) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + const meta = await operations.saveScene({ + id, + name: parsed.data.name ?? existing.name, + projectId: existing.projectId, + ownerId: existing.ownerId, + graph: parsed.data.graph as never, + thumbnailUrl: + parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl, + expectedVersion: expectedVersion ?? existing.version, + }) + return sceneApiJson(request, meta, { + headers: { ETag: `"${meta.version}"` }, + }) + } catch (error) { + return handleStoreError(request, error, { includeCurrentVersionFor: id }) + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const identity = getPascalHostIdentity(request.headers) + const identityError = hostIdentityError(request, identity) + if (identityError) return identityError + + const { id } = await params + const ifMatch = parseIfMatch(request.headers.get('If-Match')) + + const operations = await getSceneOperations() + try { + const existing = await operations.loadStoredScene(id) + if (!existing || !canAccessScene(identity, existing.ownerId)) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch }) + if (!removed) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + return withSceneApiHeaders(request, new NextResponse(null, { status: 204 })) + } catch (error) { + return handleStoreError(request, error, { includeCurrentVersionFor: id }) + } +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const identity = getPascalHostIdentity(request.headers) + const identityError = hostIdentityError(request, identity) + if (identityError) return identityError + + const { id } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return sceneApiJson( + request, + { error: 'invalid_request', details: 'body must be valid JSON' }, + { status: 400 }, + ) + } + + const parsed = patchSceneSchema.safeParse(body) + if (!parsed.success) { + return sceneApiJson( + request, + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const ifMatch = parseIfMatch(request.headers.get('If-Match')) + const expectedVersion = ifMatch ?? parsed.data.expectedVersion + + const operations = await getSceneOperations() + try { + const existing = await operations.loadStoredScene(id) + if (!existing || !canAccessScene(identity, existing.ownerId)) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion }) + return sceneApiJson(request, meta, { + headers: { ETag: `"${meta.version}"` }, + }) + } catch (error) { + return handleStoreError(request, error, { includeCurrentVersionFor: id }) + } +} + +/** + * Parses an `If-Match` header value per RFC 7232. Accepts `""` or + * weak `W/""` forms. Returns `undefined` when the header is absent, + * the wildcard `*`, or unparseable as a non-negative integer. + */ +function parseIfMatch(raw: string | null): number | undefined { + if (!raw) return undefined + const trimmed = raw.trim() + if (trimmed === '*') return undefined + const match = trimmed.match(/^(?:W\/)?"([^"]+)"$/) + const inner = match ? match[1] : trimmed + if (!inner) return undefined + const n = Number(inner) + if (!(Number.isFinite(n) && Number.isInteger(n)) || n < 0) return undefined + return n +} + +async function handleStoreError( + request: NextRequest, + error: unknown, + opts: { includeCurrentVersionFor?: string } = {}, +): Promise { + const code = (error as { code?: string })?.code + if (code === 'version_conflict') { + let currentVersion: number | undefined + if (opts.includeCurrentVersionFor) { + try { + const operations = await getSceneOperations() + const current = await operations.loadStoredScene(opts.includeCurrentVersionFor) + currentVersion = current?.version + } catch { + // Best-effort; skip reporting currentVersion on secondary failure. + } + } + return sceneApiJson( + request, + currentVersion === undefined + ? { error: 'version_conflict' } + : { error: 'version_conflict', currentVersion }, + { status: 409 }, + ) + } + if (code === 'not_found') { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + if (code === 'too_large') { + return sceneApiJson(request, { error: 'too_large' }, { status: 413 }) + } + if (code === 'invalid') { + return sceneApiJson(request, { error: 'invalid' }, { status: 400 }) + } + const message = error instanceof Error ? error.message : 'unexpected_error' + return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 }) +} diff --git a/services/pascal-editor/apps/editor/app/api/scenes/route.ts b/services/pascal-editor/apps/editor/app/api/scenes/route.ts new file mode 100644 index 000000000..8aecf2d5b --- /dev/null +++ b/services/pascal-editor/apps/editor/app/api/scenes/route.ts @@ -0,0 +1,121 @@ +import type { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { apiGraphSchema } from '@/lib/graph-schema' +import { withPascalBasePath } from '@/lib/pascal-base-path' +import { getPascalHostIdentity } from '@/lib/pascal-host-identity' +import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security' +import { getSceneOperations } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' + +const createSceneSchema = z.object({ + id: z.string().min(1).max(64).optional(), + name: z.string().min(1).max(200), + projectId: z.string().min(1).max(200).nullable().optional(), + graph: apiGraphSchema, + thumbnailUrl: z.string().url().nullable().optional(), +}) + +const listQuerySchema = z.object({ + projectId: z.string().min(1).max(200).optional(), + limit: z.coerce.number().int().positive().max(500).optional(), +}) + +export function OPTIONS(request: NextRequest) { + return sceneApiPreflight(request) +} + +export async function GET(request: NextRequest) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const identity = getPascalHostIdentity(request.headers) + if (!identity.allowed) { + return sceneApiJson(request, { error: 'host_user_required' }, { status: 401 }) + } + + const url = new URL(request.url) + const parsed = listQuerySchema.safeParse({ + projectId: url.searchParams.get('projectId') ?? undefined, + limit: url.searchParams.get('limit') ?? undefined, + }) + if (!parsed.success) { + return sceneApiJson( + request, + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const operations = await getSceneOperations() + const scenes = await operations.listScenes({ + ownerId: identity.userId ?? undefined, + projectId: parsed.data.projectId, + limit: parsed.data.limit, + }) + return sceneApiJson(request, { scenes }) +} + +export async function POST(request: NextRequest) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const identity = getPascalHostIdentity(request.headers) + if (!identity.allowed) { + return sceneApiJson(request, { error: 'host_user_required' }, { status: 401 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return sceneApiJson( + request, + { error: 'invalid_request', details: 'body must be valid JSON' }, + { status: 400 }, + ) + } + + const parsed = createSceneSchema.safeParse(body) + if (!parsed.success) { + return sceneApiJson( + request, + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const operations = await getSceneOperations() + try { + const meta = await operations.saveScene({ + id: parsed.data.id, + name: parsed.data.name, + ownerId: identity.userId, + projectId: parsed.data.projectId ?? null, + graph: parsed.data.graph as never, + thumbnailUrl: parsed.data.thumbnailUrl ?? null, + }) + return sceneApiJson(request, meta, { + status: 201, + headers: { Location: withPascalBasePath(`/scene/${meta.id}`) }, + }) + } catch (error) { + return handleStoreError(request, error) + } +} + +function handleStoreError(request: NextRequest, error: unknown): NextResponse { + const code = (error as { code?: string })?.code + if (code === 'version_conflict') { + return sceneApiJson(request, { error: 'version_conflict' }, { status: 409 }) + } + if (code === 'not_found') { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + if (code === 'too_large') { + return sceneApiJson(request, { error: 'too_large' }, { status: 413 }) + } + if (code === 'invalid') { + return sceneApiJson(request, { error: 'invalid' }, { status: 400 }) + } + const message = error instanceof Error ? error.message : 'unexpected_error' + return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 }) +} diff --git a/services/pascal-editor/apps/editor/app/client-bootstrap.tsx b/services/pascal-editor/apps/editor/app/client-bootstrap.tsx new file mode 100644 index 000000000..610eac4e6 --- /dev/null +++ b/services/pascal-editor/apps/editor/app/client-bootstrap.tsx @@ -0,0 +1,23 @@ +'use client' + +// Loads `@pascal-app/nodes`' built-in plugin into the node registry on the +// client. Mounted from `layout.tsx` so every page in the standalone +// editor gets the registry populated before its first `` / +// `` mounts — without this the registry is empty on the client +// (the server registers in its own module instance, which is unreachable +// from hydrated pages) and every `NodeRenderer` resolves to `null`. The +// `loaded` guard inside `../lib/bootstrap` keeps the side effect +// idempotent under HMR. +import '../lib/bootstrap' +import { type ReactNode, useEffect } from 'react' + +export function ClientBootstrap({ children }: { children: ReactNode }) { + useEffect(() => { + if (process.env.NODE_ENV !== 'development') return + // Loaded here (not via a `', 'data text/html'], + ['data:application/javascript,alert(1)', 'data application/javascript'], + ['data:text/plain,hi', 'data text/plain'], + ['ftp://a.b.com', 'ftp scheme'], + ['ws://example.com/', 'websocket scheme'], + ['vbscript:msgbox', 'vbscript scheme'], + ['', 'empty string'], + ['not a url at all', 'non-url string'], + ['://missing-scheme', 'malformed'], + ] + for (const [url, label] of cases) { + test(`rejects ${label}: ${url}`, () => { + expect(isValid(url)).toBe(false) + }) + } + }) + + describe(`env allowlist via ${ALLOWED_ORIGINS_ENV}`, () => { + const g = globalThis as { process?: { env?: Record } } + const original = g.process?.env?.[ALLOWED_ORIGINS_ENV] + + beforeEach(() => { + if (g.process?.env) delete g.process.env[ALLOWED_ORIGINS_ENV] + }) + + afterEach(() => { + if (!g.process?.env) return + if (original === undefined) { + delete g.process.env[ALLOWED_ORIGINS_ENV] + } else { + g.process.env[ALLOWED_ORIGINS_ENV] = original + } + }) + + test('single origin allowlist accepts matching https URL', () => { + if (!g.process?.env) return // browser-only runtime + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + expect(isValid('https://cdn.pascal.app/a.glb')).toBe(true) + expect(isValid('https://cdn.pascal.app/deep/path?q=1')).toBe(true) + }) + + test('single origin allowlist rejects non-matching https URL', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + expect(isValid('https://cdn.other.com/a.glb')).toBe(false) + expect(isValid('https://attacker.example.com/x')).toBe(false) + }) + + test('multi-origin allowlist accepts any listed origin', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app, https://assets.pascal.app' + expect(isValid('https://cdn.pascal.app/a.glb')).toBe(true) + expect(isValid('https://assets.pascal.app/tex.webp')).toBe(true) + expect(isValid('https://third.example.com/x')).toBe(false) + }) + + test('allowlist ignores trailing / in URL path (origin match only)', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + expect(isValid('https://cdn.pascal.app/')).toBe(true) + expect(isValid('https://cdn.pascal.app')).toBe(true) + }) + + test('empty allowlist behaves like unset', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = '' + expect(isValid('https://cdn.other.com/a.glb')).toBe(true) + }) + + test('allowlist does not restrict non-https schemes', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + // these should still pass because they match earlier scheme-based branches + expect(isValid('asset://x')).toBe(true) + expect(isValid('blob:https://example.com/abc')).toBe(true) + expect(isValid('data:image/png;base64,AAA')).toBe(true) + expect(isValid('/public/a.glb')).toBe(true) + expect(isValid('http://localhost:3000/x')).toBe(true) + }) + + test('allowlist rejects subdomain spoofing', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + expect(isValid('https://cdn.pascal.app.evil.com/x')).toBe(false) + expect(isValid('https://evil.com/cdn.pascal.app')).toBe(false) + }) + + test('allowlist respects ports', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app:8443' + expect(isValid('https://cdn.pascal.app:8443/x')).toBe(true) + expect(isValid('https://cdn.pascal.app/x')).toBe(false) + }) + }) +}) diff --git a/services/pascal-editor/packages/core/src/schema/asset-url.ts b/services/pascal-editor/packages/core/src/schema/asset-url.ts new file mode 100644 index 000000000..5497bd26c --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/asset-url.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' + +/** + * Scheme allowlist for asset-like URLs embedded in scene graphs. + * + * Phase 3 security audit: `scan.url`, `guide.url`, `material.texture.url`, and + * `item.asset.src` were previously bare `z.string()`. That meant an + * attacker-crafted scene loaded in the editor could beacon to arbitrary URLs + * (e.g. `javascript:`, `file:///etc/passwd`, `http://169.254.169.254/...`). + * + * This validator rejects URLs that don't match the scheme allowlist below. + */ +const ALLOWED_SCHEMES = ['asset:', 'blob:', 'https:', 'data:image/'] as const + +/** + * Optional environment variable that narrows which `https:` origins are + * accepted. Set to a comma-separated list (e.g. `https://cdn.pascal.app`). + * When unset, any `https:` origin is permitted. + */ +export const ALLOWED_ORIGINS_ENV = 'PASCAL_ALLOWED_ASSET_ORIGINS' + +// Narrow access to the environment variable without requiring @types/node in +// this package. The core package ships to both browser and Node contexts. +function readAllowedOrigins(): readonly string[] | undefined { + const g = globalThis as { process?: { env?: Record } } + const value = g.process?.env?.[ALLOWED_ORIGINS_ENV] + if (!value) return undefined + const list = value + .split(',') + .map((s: string) => s.trim()) + .filter((s: string) => s.length > 0) + return list.length > 0 ? list : undefined +} + +function isAllowedAssetUrl(url: string): boolean { + if (typeof url !== 'string' || url.length === 0) return false + if (url.startsWith('asset://')) return true // internal handle + if (url.startsWith('blob:')) return true // in-memory reference + if (url.startsWith('data:image/')) return true // inline image only (never data:text/html) + if (url.startsWith('/')) return true // app-relative path + try { + const parsed = new URL(url) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false + // http is only permitted for localhost development + if (parsed.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(parsed.hostname)) { + return false + } + // optional env-driven origin allowlist (only enforced for https URLs) + if (parsed.protocol === 'https:') { + const allowlist = readAllowedOrigins() + if (allowlist) return allowlist.includes(parsed.origin) + } + return true + } catch { + return false + } +} + +/** + * Zod validator for asset-style URL fields. Accepts: + * - `asset://…` internal handles + * - `blob:…` in-memory references + * - `data:image/…` inline images (not `data:text/html` or other types) + * - `/…` app-relative paths + * - `https://…` public URLs (optionally narrowed to an env allowlist) + * - `http://localhost[:port]/…` or `http://127.0.0.1/…` for local dev + * + * Rejects every other scheme, including `javascript:`, `file:`, `ftp:`, + * and `data:text/html`, as well as empty strings and non-URL garbage. + */ +export const AssetUrl = z.string().refine(isAllowedAssetUrl, { + message: + 'URL must be asset://, blob:, data:image/, /path, or https://. http://localhost allowed for dev.', +}) + +export type AssetUrl = z.infer + +// re-export the scheme allowlist for documentation / downstream validators +export { ALLOWED_SCHEMES } diff --git a/services/pascal-editor/packages/core/src/schema/base.ts b/services/pascal-editor/packages/core/src/schema/base.ts new file mode 100644 index 000000000..0e24ddf2f --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/base.ts @@ -0,0 +1,32 @@ +import { customAlphabet } from 'nanoid' +import { z } from 'zod' +import { CameraSchema } from './camera' + +const customId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16) + +/** + * Material preset name reference + * @example 'white', 'brick', 'wood', 'glass', 'preview-valid' + */ +export const Material = z.string().optional() +export const generateId = (prefix: T): `${T}_${string}` => + `${prefix}_${customId()}` as `${T}_${string}` +export const objectId = (prefix: T) => { + const schema = z.templateLiteral([`${prefix}_`, z.string()]) + + return schema.default(() => generateId(prefix) as z.infer) +} +export const nodeType = (type: T) => z.literal(type).default(type) + +export const BaseNode = z.object({ + object: z.literal('node').default('node'), + id: z.string(), // objectId('node'), @Aymericr: Thing is if we specify objectId here, when using BaseNode.extend, TS complains that the id is not assignable to the more specific type in the extended node + type: nodeType('node'), + name: z.string().optional(), + parentId: z.string().nullable().default(null), + visible: z.boolean().optional().default(true), + camera: CameraSchema.optional(), + metadata: z.json().optional().default({}), +}) + +export type BaseNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/camera.ts b/services/pascal-editor/packages/core/src/schema/camera.ts new file mode 100644 index 000000000..c691eabaf --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/camera.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +const Vector3Schema = z.tuple([z.number(), z.number(), z.number()]) + +export const CameraSchema = z.object({ + position: Vector3Schema, + target: Vector3Schema, + mode: z.enum(['perspective', 'orthographic']).default('perspective'), + fov: z.number().optional(), // For perspective + zoom: z.number().optional(), // For orthographic +}) + +export type Camera = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/collections.ts b/services/pascal-editor/packages/core/src/schema/collections.ts new file mode 100644 index 000000000..32fb01c8b --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/collections.ts @@ -0,0 +1,14 @@ +import { generateId } from './base' +import type { AnyNodeId } from './types' + +export type CollectionId = `collection_${string}` + +export type Collection = { + id: CollectionId + name: string + color?: string + nodeIds: AnyNodeId[] + controlNodeId?: AnyNodeId +} + +export const generateCollectionId = (): CollectionId => generateId('collection') diff --git a/services/pascal-editor/packages/core/src/schema/index.ts b/services/pascal-editor/packages/core/src/schema/index.ts new file mode 100644 index 000000000..b0f0d4cd3 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/index.ts @@ -0,0 +1,202 @@ +// Base + +export { + SOLAR_PANEL_PRESET_LABELS, + SOLAR_PANEL_PRESETS, + type SolarPanelPresetDims, + SolarPanelPresetKey, +} from '../solar-panel-presets' +export { BaseNode, generateId, Material, nodeType, objectId } from './base' +// Camera +export { CameraSchema } from './camera' +// Collections +export { type Collection, type CollectionId, generateCollectionId } from './collections' +export type { + MaterialMapProperties, + MaterialMaps, + MaterialPresetPayload, + MaterialTarget as MaterialTargetValue, + TextureWrapMode as TextureWrapModeValue, +} from './material' +// Material +export { + DEFAULT_MATERIALS, + MaterialMapPropertiesSchema, + MaterialMapsSchema, + MaterialPreset, + MaterialPresetPayloadSchema, + MaterialProperties, + MaterialSchema, + MaterialTarget, + resolveMaterial, + TextureWrapMode, +} from './material' +export { BoxVentNode } from './nodes/box-vent' +export { BuildingNode } from './nodes/building' +export { CeilingNode } from './nodes/ceiling' +export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' +export { + COLUMN_PRESETS, + ColumnBaseStyle, + ColumnCapitalStyle, + ColumnCarvingPlacement, + ColumnCrossSection, + ColumnNode, + ColumnPanelShape, + type ColumnPresetId, + ColumnRingPlacement, + ColumnShaftDetail, + ColumnShaftProfile, + ColumnStyle, + ColumnSupportStyle, +} from './nodes/column' +export { CupolaNode } from './nodes/cupola' +export { DoorNode, DoorSegment } from './nodes/door' +export { + DormerNode, + type DormerSurfaceMaterialRole, + type DormerSurfaceMaterialSpec, + getEffectiveDormerSurfaceMaterial, +} from './nodes/dormer' +export { DownspoutNode } from './nodes/downspout' +export { DuctFittingNode } from './nodes/duct-fitting' +export { DuctSegmentNode } from './nodes/duct-segment' +export { DuctTerminalNode } from './nodes/duct-terminal' +export { + ElevatorDoorPanelStyle, + ElevatorDoorStyle, + ElevatorNode, + ElevatorShaftStyle, +} from './nodes/elevator' +export { EyebrowVentNode } from './nodes/eyebrow-vent' +export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' +export { GuideNode, GuideScaleReference } from './nodes/guide' +export { GutterNode, GutterOutlet } from './nodes/gutter' +export { HvacEquipmentNode } from './nodes/hvac-equipment' +export type { + AnimationEffect, + Asset, + AssetInput, + Control, + Effect, + Interactive, + LightEffect, + SliderControl, + TemperatureControl, + ToggleControl, +} from './nodes/item' +export { + getScaledDimensions, + ItemNode, + isLowProfileItemSurface, + LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, +} from './nodes/item' +export { LevelNode } from './nodes/level' +export { LinesetNode } from './nodes/lineset' +export { LiquidLineNode } from './nodes/liquid-line' +export { PipeFittingNode } from './nodes/pipe-fitting' +export { PipeSegmentNode } from './nodes/pipe-segment' +export { PipeTrapNode } from './nodes/pipe-trap' +// Nodes +export { + createDefaultRidgeVentsForSegment, + getRidgeVentLinesForSegment, + hasAutoRidgeVentMetadata, + isAutoRidgeVentEnabled, + isDefaultRidgeVentNode, + type RidgeVentLine, + RidgeVentNode, +} from './nodes/ridge-vent' +export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' +export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' +export type { + DutchRoofMetrics, + RoofSegmentSurfaceMaterialRole, + RoofSegmentSurfaceMaterialSpec, + RoofSegmentVisibleTopBounds, + SegmentSlopeFrame, +} from './nodes/roof-segment' +export { + getActiveRoofHeight, + getDutchRoofMetrics, + getEffectiveSegmentSurfaceMaterial, + getPitchFromActiveRoofHeight, + getRoofSegmentSurfaceY, + getRoofSegmentVisibleTopBounds, + getSegmentSlopeFrame, + hasSegmentMaterialOverride, + MIN_ROOF_SEGMENT_TRIM_SPAN, + normalizeRoofSegmentTrim, + ROOF_SHAPE_DEFAULTS, + RoofSegmentNode, + RoofSegmentTrim, + RoofType, +} from './nodes/roof-segment' +export type { + DutchRoofShapeMetrics, + RoofShapeFaceVertex, + RoofShapeInsets, + RoofShapeRatios, +} from './nodes/roof-segment-shape' +export { + getDutchEndSlopeFaces, + getDutchRoofShapeMetrics, + getRoofModuleFaces, + getRoofShapeInsets, + getRoofShapeRatios, +} from './nodes/roof-segment-shape' +export type { RoofSegmentWallFace, RoofWallFaceId } from './nodes/roof-segment-walls' +export { + clampRectToRoofWallFace, + getMaxRoofRectHeightFromAnchor, + getMaxRoofRectWidthFromAnchor, + getRoofSegmentWallFace, + getRoofSegmentWallFaces, + getRoofWallFaceFrame, + roofFacePointToSegment, + segmentPointToRoofWallFace, +} from './nodes/roof-segment-walls' +export { ScanNode } from './nodes/scan' +export { ShelfNode } from './nodes/shelf' +export { SiteNode } from './nodes/site' +export { + SKYLIGHT_TYPE_ORDER, + SKYLIGHT_TYPE_PRESETS, + SkylightMaterialRole, + SkylightNode, + SkylightOpeningSide, + SkylightSlideDirection, + SkylightType, + type SkylightTypePreset, +} from './nodes/skylight' +export { SlabNode } from './nodes/slab' +export { + SolarPanelMaterialRole, + SolarPanelNode, +} from './nodes/solar-panel' +export { SpawnNode } from './nodes/spawn' +export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' +export { + getEffectiveStairSurfaceMaterial, + StairNode, + StairRailingMode, + StairSlabOpeningMode, + StairTopLandingMode, + StairType, +} from './nodes/stair' +export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' +export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' +export { TurbineVentNode } from './nodes/turbine-vent' +export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' +export { + getEffectiveWallSurfaceMaterial, + getWallSurfaceMaterialSignature, + WALL_SLOT_DEFAULT, + WallNode, +} from './nodes/wall' +export { WindowNode, WindowType } from './nodes/window' +export { ZoneNode } from './nodes/zone' +export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material' +export type { AnyNodeId, AnyNodeType } from './types' +// Union types +export { AnyNode } from './types' diff --git a/services/pascal-editor/packages/core/src/schema/material.test.ts b/services/pascal-editor/packages/core/src/schema/material.test.ts new file mode 100644 index 000000000..a6e521461 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/material.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { MaterialSchema } from './material' + +describe('MaterialSchema', () => { + describe('preset', () => { + test('valid preset passes through unchanged', () => { + const result = MaterialSchema.parse({ preset: 'brick' }) + expect(result.preset).toBe('brick') + }) + + test('every enum preset is accepted', () => { + const presets = [ + 'white', + 'brick', + 'concrete', + 'wood', + 'glass', + 'metal', + 'plaster', + 'tile', + 'marble', + 'custom', + ] as const + for (const preset of presets) { + expect(MaterialSchema.parse({ preset }).preset).toBe(preset) + } + }) + + test("unknown preset coerces to 'custom' instead of throwing (Sentry MONOREPO-EDITOR-DB)", () => { + const result = MaterialSchema.parse({ preset: 'stone' }) + expect(result.preset).toBe('custom') + }) + + test("non-string preset coerces to 'custom'", () => { + const result = MaterialSchema.parse({ preset: 42 }) + expect(result.preset).toBe('custom') + }) + + test('missing preset stays undefined', () => { + const result = MaterialSchema.parse({}) + expect(result.preset).toBeUndefined() + }) + + test('explicit undefined preset stays undefined', () => { + const result = MaterialSchema.parse({ preset: undefined }) + expect(result.preset).toBeUndefined() + }) + }) +}) diff --git a/services/pascal-editor/packages/core/src/schema/material.ts b/services/pascal-editor/packages/core/src/schema/material.ts new file mode 100644 index 000000000..0c8fcae5a --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/material.ts @@ -0,0 +1,215 @@ +import { z } from 'zod' +import { AssetUrl } from './asset-url' + +export const MaterialPreset = z.enum([ + 'white', + 'brick', + 'concrete', + 'wood', + 'glass', + 'metal', + 'plaster', + 'tile', + 'marble', + 'custom', +]) +export type MaterialPreset = z.infer + +export const MaterialProperties = z.object({ + color: z.string().default('#ffffff'), + roughness: z.number().min(0).max(1).default(0.5), + metalness: z.number().min(0).max(1).default(0), + opacity: z.number().min(0).max(1).default(1), + transparent: z.boolean().default(false), + side: z.enum(['front', 'back', 'double']).default('front'), +}) +export type MaterialProperties = z.infer + +export const MaterialSchema = z.object({ + id: z.string().optional(), + // Coerce unknown presets (legacy/AI-generated data) to 'custom' instead of throwing. + preset: MaterialPreset.catch('custom').optional(), + properties: MaterialProperties.optional(), + texture: z + .object({ + url: AssetUrl, + repeat: z.tuple([z.number(), z.number()]).optional(), + scale: z.number().optional(), + }) + .optional(), +}) +export type MaterialSchema = z.infer + +export const MaterialTarget = z.enum([ + 'wall', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'fence', + 'column', + 'slab', + 'ceiling', + 'door', + 'window', + 'shelf', + 'chimney', + 'skylight', + 'dormer', + 'box-vent', + 'ridge-vent', + 'turbine-vent', + 'cupola', + 'eyebrow-vent', + 'gutter', +]) +export type MaterialTarget = z.infer + +export const TextureWrapMode = z.enum(['Repeat', 'ClampToEdge', 'MirroredRepeat']) +export type TextureWrapMode = z.infer + +export const MaterialMapsSchema = z.object({ + albedoMap: AssetUrl.optional(), + metalnessMap: AssetUrl.optional(), + roughnessMap: AssetUrl.optional(), + normalMap: AssetUrl.optional(), + displacementMap: AssetUrl.optional(), + aoMap: AssetUrl.optional(), + emissiveMap: AssetUrl.optional(), + bumpMap: AssetUrl.optional(), + alphaMap: AssetUrl.optional(), + lightMap: AssetUrl.optional(), +}) +export type MaterialMaps = z.infer + +export const MaterialMapPropertiesSchema = z.object({ + color: z.string().default('#ffffff'), + roughness: z.number().min(0).max(1).default(0.5), + metalness: z.number().min(0).max(1).default(0), + repeatX: z.number().default(1), + repeatY: z.number().default(1), + rotation: z.number().default(0), + wrapS: TextureWrapMode.default('Repeat'), + wrapT: TextureWrapMode.default('Repeat'), + normalScaleX: z.number().default(1), + normalScaleY: z.number().default(1), + emissiveIntensity: z.number().default(1), + displacementScale: z.number().default(0.02), + transparent: z.boolean().default(false), + flipY: z.boolean().default(true), + bumpScale: z.number().default(1), + emissiveColor: z.string().default('#000000'), + aoMapIntensity: z.number().default(1), + side: z.number().default(0), + opacity: z.number().min(0).max(1).default(1), + lightMapIntensity: z.number().default(1), +}) +export type MaterialMapProperties = z.infer + +export const MaterialPresetPayloadSchema = z.object({ + maps: MaterialMapsSchema, + mapProperties: MaterialMapPropertiesSchema, +}) +export type MaterialPresetPayload = z.infer + +export const DEFAULT_MATERIALS: Record = { + white: { + color: '#ffffff', + roughness: 0.9, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + brick: { + color: '#8b4513', + roughness: 0.85, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + concrete: { + color: '#808080', + roughness: 0.8, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + wood: { + color: '#deb887', + roughness: 0.7, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + glass: { + color: '#87ceeb', + roughness: 0.1, + metalness: 0.1, + opacity: 0.3, + transparent: true, + side: 'double', + }, + metal: { + color: '#c0c0c0', + roughness: 0.3, + metalness: 0.9, + opacity: 1, + transparent: false, + side: 'front', + }, + plaster: { + color: '#f5f5dc', + roughness: 0.95, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + tile: { + color: '#d3d3d3', + roughness: 0.4, + metalness: 0.1, + opacity: 1, + transparent: false, + side: 'front', + }, + marble: { + color: '#fafafa', + roughness: 0.2, + metalness: 0.1, + opacity: 1, + transparent: false, + side: 'front', + }, + custom: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, +} + +export function resolveMaterial(material?: MaterialSchema): MaterialProperties { + if (!material) { + return DEFAULT_MATERIALS.white + } + + if (material.preset && material.preset !== 'custom') { + const presetProps = DEFAULT_MATERIALS[material.preset] + return { + ...presetProps, + ...material.properties, + } + } + + return { + ...DEFAULT_MATERIALS.custom, + ...material.properties, + } +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/box-vent.ts b/services/pascal-editor/packages/core/src/schema/nodes/box-vent.ts new file mode 100644 index 000000000..23e27a59b --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/box-vent.ts @@ -0,0 +1,73 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const BoxVentNode = BaseNode.extend({ + id: objectId('bvent'), + type: nodeType('box-vent'), + + material: MaterialSchema.optional(), + // Default to the white preset so newly-placed vents read as clean + // painted metal — and so the paint inspector shows "White" as the + // current selection instead of an empty "no material" state, which + // made it look like the vent had nothing applied even though the + // renderer was falling back to white internally. + materialPreset: z.string().default('preset-white'), + + roofSegmentId: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + width: z.number().default(0.4), + depth: z.number().default(0.4), + height: z.number().default(0.15), + // `cap` + `dome` only. Width of the flange skirt past the body. Ignored + // by the `box` style (no flange). + hoodOverhang: z.number().default(0.04), + // `cap`-only: how much the pyramid hood narrows at the top. 0 = no + // taper (flat-top column), 1 = comes to a point. ~0.4 reads as a + // classic attic-vent cap. Ignored by `box` / `dome`. + topTaper: z.number().default(0.4), + // `cap`-only: absolute height of the chamfered cap section. The body + // walls take up the rest of the total `height`. Together with + // `topTaper`, this controls the visible chamfer angle. + capHeight: z.number().default(0.07), + // `cap`-only: vertical air gap between the body's closed top and the + // cap's flange. Reads as the ventilation slot on real attic vents. + // 0 = cap sits flush on the body (the original cap shape). + capGap: z.number().default(0), + // `dome`-only: radial decay of the dome cap. 1 = clean ellipsoid + // (default — reads as a proper hemispherical dome), <1 = fuller pillow, + // >1 = pointier. Ignored by `box` / `cap`. + domeCurvature: z.number().default(1.0), + // `box`-only: how much the lower (smaller) riser is inset from the + // upper (larger) cover. 0 = same footprint as the cover. + baseInset: z.number().default(0.06), + // `box`-only: height of the lower riser. The upper cover takes up + // the remaining `height - baseHeight`. + baseHeight: z.number().default(0.04), + // `box`-only: radius of the corner chamfer on both stacked boxes. + // 0 = sharp corners. + cornerBevel: z.number().default(0.012), + + // Migrate legacy style values (`standard` / `low-profile`) saved in + // older scenes to the new three-way enum so old data keeps parsing. + style: z.preprocess( + (value) => { + if (value === 'standard') return 'cap' + if (value === 'low-profile') return 'box' + return value + }, + z.enum(['box', 'cap', 'dome']).default('cap'), + ), +}).describe( + dedent` + Box vent node — a small louvered ventilation box that sits on a roof slope. + Often used in groups for attic exhaust ventilation. Parented to a + roof-segment; position is segment-local. Rotation is around the + segment's vertical axis (post-slope tilt). + `, +) + +export type BoxVentNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/building.ts b/services/pascal-editor/packages/core/src/schema/nodes/building.ts new file mode 100644 index 000000000..e4a4e470f --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/building.ts @@ -0,0 +1,22 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { ElevatorNode } from './elevator' +import { LevelNode } from './level' + +export const BuildingNode = BaseNode.extend({ + id: objectId('building'), + type: nodeType('building'), + children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), +}).describe( + dedent` + Building node - used to represent a building + - position: position in site coordinate system + - rotation: rotation in site coordinate system + - children: array of level nodes and building-level systems such as elevators + `, +) + +export type BuildingNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/ceiling.ts b/services/pascal-editor/packages/core/src/schema/nodes/ceiling.ts new file mode 100644 index 000000000..b94aaaa2b --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/ceiling.ts @@ -0,0 +1,33 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' +import { ItemNode } from './item' +import { SurfaceHoleMetadata } from './surface-hole-metadata' + +export const CeilingNode = BaseNode.extend({ + id: objectId('ceiling'), + type: nodeType('ceiling'), + children: z.array(ItemNode.shape.id).default([]), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef` + // (`library:` / `scene:`). Absent = the declared slot default. + slots: z.record(z.string(), z.string()).optional(), + polygon: z.array(z.tuple([z.number(), z.number()])), + holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), + holeMetadata: z.array(SurfaceHoleMetadata).default([]), + height: z.number().default(2.5), // Height in meters + autoFromWalls: z.boolean().default(false), +}).describe( + dedent` + Ceiling node - used to represent a ceiling in the building + - polygon: array of [x, z] points defining the ceiling boundary + - holes: array of polygons representing holes in the ceiling + - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts + - autoFromWalls: whether the ceiling is automatically generated from a closed wall loop + `, +) + +export type CeilingNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/chimney.ts b/services/pascal-editor/packages/core/src/schema/nodes/chimney.ts new file mode 100644 index 000000000..20ae1dcad --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/chimney.ts @@ -0,0 +1,83 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ChimneyMaterialRole = z.enum(['body', 'top']) +export type ChimneyMaterialRole = z.infer + +export const ChimneyNode = BaseNode.extend({ + id: objectId('chimney'), + type: nodeType('chimney'), + + // Body material — body, bands, flues, cricket. + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + // Top material — cap surface. Falls back to `material` if unset. + topMaterial: MaterialSchema.optional(), + topMaterialPreset: z.string().optional(), + + // Host segment + segment-local 2D position. The Y component is + // ignored; placement is anchored to the segment's pitched surface + // by the renderer. + roofSegmentId: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + bodyShape: z.enum(['square', 'round']).default('square'), + bodyHollowDepth: z.number().default(0.6), + bodyHollowMargin: z.number().default(0.08), + width: z.number().default(0.6), + depth: z.number().default(0.6), + heightAboveRidge: z.number().default(1.0), + cutoutOffset: z.number().default(0), + // Chamfered corners on the body / cap / bands. 0 = sharp 90° corners + // (existing behaviour, kept as default so previously-saved chimneys + // look identical). A small positive value (~0.01-0.02 m) breaks up + // the silhouette and reads as a stone or chamfered-brick chimney. + // Square bodies only — round bodies have no corners to bevel. + cornerBevel: z.number().default(0), + + cap: z.boolean().default(true), + capShape: z.enum(['none', 'sloped', 'flat', 'stepped']).default('sloped'), + capOverhang: z.number().default(0.04), + capThickness: z.number().default(0.08), + + flueCount: z.number().int().min(0).max(4).default(1), + flueShape: z.enum(['round', 'square']).default('round'), + flueHeight: z.number().default(0.3), + flueDiameter: z.number().default(0.22), + flueSpacing: z.number().default(1), + flueWallThickness: z.number().default(0.02), + + shoulderStyle: z.enum(['none', 'tapered', 'corbeled']).default('none'), + shoulderHeight: z.number().default(0.5), + shoulderExtent: z.number().default(0.1), + + bandStyle: z.enum(['none', 'single', 'double']).default('none'), + bandHeight: z.number().default(0.1), + bandExtent: z.number().default(0.04), + bandOffset: z.number().default(0.4), + + cricketStyle: z.enum(['none', 'simple']).default('none'), + cricketLength: z.number().default(0.6), + cricketHeight: z.number().default(0.4), + cricketSide: z.enum(['front', 'back']).default('front'), + + panelStyle: z.enum(['none', 'rectangular']).default('none'), + panelDepth: z.number().default(0.03), + panelHeight: z.number().default(0.8), + panelOffsetTop: z.number().default(0.15), + panelMargin: z.number().default(0.1), +}).describe( + dedent` + Chimney — a vertical brick/stone stack hosted on a roof segment. + + Position is segment-local (x along segment width, z along segment + depth). The Y component is ignored — placement anchors to the + segment's pitched surface. Rotation is yaw around the world-vertical + axis (chimneys stay vertical regardless of the slope). + `, +) + +export type ChimneyNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/column.ts b/services/pascal-editor/packages/core/src/schema/nodes/column.ts new file mode 100644 index 000000000..de4116045 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/column.ts @@ -0,0 +1,995 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ColumnStyle = z.enum([ + 'plain', + 'faceted', + 'fluted', + 'lathe-turned', + 'dravidian-carved', + 'cluster', +]) + +export const ColumnCrossSection = z.enum([ + 'round', + 'square', + 'rectangular', + 'octagonal', + 'sixteen-sided', +]) + +export const ColumnShaftProfile = z.enum(['straight', 'tapered', 'bulged', 'baluster', 'hourglass']) + +export const ColumnShaftDetail = z.enum(['none', 'fluted', 'spiral', 'panelled', 'lathe-turned']) + +export const ColumnPanelShape = z.enum(['rectangle', 'arched', 'diamond']) + +export const ColumnBaseStyle = z.enum([ + 'none', + 'simple-square', + 'round-rings', + 'square-plinth', + 'stepped-square', + 'lotus', + 'ribbed-lotus', + 'panelled-pedestal', +]) + +export const ColumnCapitalStyle = z.enum([ + 'none', + 'simple', + 'simple-slab', + 'rounded', + 'stepped', + 'doric', + 'volute', + 'ionic-volute', + 'leaf-carved', + 'corinthian-leaf', + 'south-indian-bracket', + 'wood-bracket', +]) + +export const ColumnRingPlacement = z.enum(['ends', 'even', 'top', 'bottom']) + +export const ColumnCarvingPlacement = z.enum(['shaft', 'base', 'capital', 'all']) + +export const ColumnSupportStyle = z.enum([ + 'vertical', + 'a-frame', + 'y-frame', + 'v-frame', + 'x-brace', + 'k-brace', + 'single-strut', + 'tripod', + 'trestle', + 'portal-frame', + 'box-frame', +]) + +export type ColumnStyle = z.infer +export type ColumnCrossSection = z.infer +export type ColumnShaftProfile = z.infer +export type ColumnShaftDetail = z.infer +export type ColumnPanelShape = z.infer +export type ColumnBaseStyle = z.infer +export type ColumnCapitalStyle = z.infer +export type ColumnRingPlacement = z.infer +export type ColumnCarvingPlacement = z.infer +export type ColumnSupportStyle = z.infer + +export const ColumnNode = BaseNode.extend({ + id: objectId('column'), + type: nodeType('column'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + style: ColumnStyle.default('plain'), + crossSection: ColumnCrossSection.default('round'), + height: z.number().positive().default(2.5), + radius: z.number().positive().default(0.22), + width: z.number().positive().default(0.44), + depth: z.number().positive().default(0.44), + edgeSoftness: z.number().min(0).max(0.12).default(0.025), + baseHeight: z.number().min(0).default(0.18), + capitalHeight: z.number().min(0).default(0.18), + shaftProfile: ColumnShaftProfile.default('straight'), + shaftTaper: z.number().min(0).max(0.85).default(0), + shaftBulge: z.number().min(-0.5).max(0.8).default(0), + shaftStartScale: z.number().min(0.2).max(2).default(0.72), + shaftEndScale: z.number().min(0.2).max(2).default(0.72), + shaftSegmentCount: z.number().int().min(1).max(64).default(24), + shaftTwistStep: z.number().min(-90).max(90).default(0), + shaftCornerRadius: z.number().min(0).max(0.3).default(0.035), + shaftDetail: ColumnShaftDetail.default('none'), + baseStyle: ColumnBaseStyle.default('round-rings'), + baseWidthScale: z.number().min(0.4).max(3).default(1.24), + baseDepthScale: z.number().min(0.4).max(3).default(1.24), + baseTierCount: z.number().int().min(1).max(8).default(3), + baseStepSpread: z.number().min(0).max(1).default(0.42), + basePlinthHeightRatio: z.number().min(0.2).max(0.7).default(0.44), + baseRoundBandScale: z.number().min(0.5).max(1.2).default(0.92), + baseNeckScale: z.number().min(0.35).max(1).default(0.72), + baseRoundBandCount: z.number().int().min(0).max(16).default(3), + baseRibCount: z.number().int().min(0).max(48).default(0), + baseCarvingLevel: z.number().int().min(0).max(4).default(0), + basePanelInset: z.number().min(0).max(0.1).default(0.02), + capitalStyle: ColumnCapitalStyle.default('simple'), + capitalWidthScale: z.number().min(0.4).max(3).default(1.46), + capitalDepthScale: z.number().min(0.4).max(3).default(1.46), + capitalTierCount: z.number().int().min(1).max(8).default(3), + capitalStepSpread: z.number().min(0).max(1).default(0.42), + capitalBandCount: z.number().int().min(0).max(16).default(2), + voluteSize: z.number().min(0.02).max(0.3).default(0.08), + voluteCount: z.number().int().min(0).max(8).default(4), + leafCount: z.number().int().min(0).max(48).default(18), + leafRows: z.number().int().min(0).max(4).default(2), + bracketDepth: z.number().min(0).max(1.5).default(0.35), + bracketTierCount: z.number().int().min(0).max(8).default(3), + pendantCount: z.number().int().min(0).max(16).default(0), + capitalCarvingLevel: z.number().int().min(0).max(4).default(0), + ringCount: z.number().int().min(0).max(16).default(0), + ringPlacement: ColumnRingPlacement.default('ends'), + ringThickness: z.number().min(0.01).max(0.14).default(0.055), + ringSpread: z.number().min(0.04).max(0.45).default(0.16), + fluteCount: z.number().int().min(0).max(32).default(0), + fluteDepth: z.number().min(0.005).max(0.08).default(0.02), + fluteWidth: z.number().min(0.005).max(0.1).default(0.02), + spiralTwist: z.number().min(-3).max(3).default(0), + spiralRibCount: z.number().int().min(0).max(32).default(0), + panelCount: z.number().int().min(0).max(24).default(0), + panelInsetDepth: z.number().min(0).max(0.1).default(0.02), + panelShape: ColumnPanelShape.default('rectangle'), + latheRingCount: z.number().int().min(0).max(32).default(0), + latheRingSpacing: ColumnRingPlacement.default('ends'), + carvingLevel: z.number().int().min(0).max(4).default(0), + carvingPlacement: ColumnCarvingPlacement.default('capital'), + lowerBandEnabled: z.boolean().default(false), + lowerBandHeight: z.number().min(0).max(1).default(0.24), + lowerBandCarvingLevel: z.number().int().min(0).max(4).default(0), + dentilCount: z.number().int().min(0).max(48).default(0), + beadCount: z.number().int().min(0).max(64).default(0), + supportStyle: ColumnSupportStyle.default('vertical'), + braceWidth: z.number().positive().default(0.16), + braceDepth: z.number().positive().default(0.16), + braceBottomSpread: z.number().min(0.2).default(1.2), + braceTopSpread: z.number().min(0).default(0.12), + bracePlateEnabled: z.boolean().default(true), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + // Unified paint-slot refs (`scene:`/`library:` MaterialRef per slot id), + // matching the slot model items/slab/shelf use. Absent = declared default. + slots: z.record(z.string(), z.string()).optional(), +}).describe(dedent` + Column node - used to represent structural or decorative pillars/columns. + - style: visual approach such as plain, lathe-turned, carved, or cluster + - crossSection: plan shape used by the procedural renderer + - height/radius/width/depth: primary dimensions in meters + - edgeSoftness: bevel radius for square/plinth/block edges + - shaftProfile/shaftDetail: profile and surface treatment of the shaft + - shaftTwistStep: per-segment shaft rotation in degrees + - shaftCornerRadius: rounded-corner radius for square/rectangular shaft cross-sections + - baseStyle/capitalStyle: procedural base and top treatment with tier/detail controls + - baseHeight/capitalHeight: bottom and top block proportions + - ring/flute/spiral/panel/lathe/carving fields: procedural detail controls + - supportStyle/brace fields: vertical column or procedural support assembly +`) + +export const COLUMN_PRESETS = { + basicPillar: { + label: 'Straight Round', + style: 'plain', + crossSection: 'round', + height: 2.5, + radius: 0.22, + width: 0.44, + depth: 0.44, + edgeSoftness: 0.025, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 0.72, + shaftEndScale: 0.72, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.26, + baseDepthScale: 1.26, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.22, + capitalDepthScale: 1.22, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + squarePillar: { + label: 'Square Block', + style: 'faceted', + crossSection: 'square', + height: 2.5, + radius: 0.24, + width: 0.48, + depth: 0.48, + edgeSoftness: 0.035, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 0.72, + shaftEndScale: 0.72, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.045, + shaftDetail: 'none', + baseStyle: 'simple-square', + baseWidthScale: 1.18, + baseDepthScale: 1.18, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.18, + capitalDepthScale: 1.18, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + taperedPillar: { + label: 'Tapered Round', + style: 'plain', + crossSection: 'round', + height: 2.5, + radius: 0.23, + width: 0.46, + depth: 0.46, + edgeSoftness: 0.025, + baseHeight: 0.23, + capitalHeight: 0.2, + shaftProfile: 'tapered', + shaftTaper: 0.14, + shaftBulge: 0, + shaftStartScale: 0.82, + shaftEndScale: 0.72, + shaftSegmentCount: 32, + shaftTwistStep: 0, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.26, + baseDepthScale: 1.26, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.22, + capitalDepthScale: 1.22, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + bulgedPillar: { + label: 'Soft Bulged', + style: 'plain', + crossSection: 'round', + height: 2.5, + radius: 0.22, + width: 0.44, + depth: 0.44, + edgeSoftness: 0.025, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'bulged', + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.68, + shaftEndScale: 0.68, + shaftSegmentCount: 32, + shaftTwistStep: 0, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.24, + baseDepthScale: 1.24, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.2, + capitalDepthScale: 1.2, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + hourglassPillar: { + label: 'Hourglass', + style: 'plain', + crossSection: 'round', + height: 2.5, + radius: 0.22, + width: 0.44, + depth: 0.44, + edgeSoftness: 0.025, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'hourglass', + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.84, + shaftEndScale: 0.84, + shaftSegmentCount: 32, + shaftTwistStep: 0, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.24, + baseDepthScale: 1.24, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.2, + capitalDepthScale: 1.2, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + aFrameSupport: { + label: 'A-Frame Support', + supportStyle: 'a-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 1.2, + braceTopSpread: 0.06, + bracePlateEnabled: false, + }, + yFrameSupport: { + label: 'Y Support', + supportStyle: 'y-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 0.2, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + vFrameSupport: { + label: 'V Support', + supportStyle: 'v-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 0.2, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + xBraceSupport: { + label: 'X Brace', + supportStyle: 'x-brace', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + kBraceSupport: { + label: 'K Brace', + supportStyle: 'k-brace', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + singleStrutSupport: { + label: 'Single Strut', + supportStyle: 'single-strut', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + tripodSupport: { + label: 'Tripod Support', + supportStyle: 'tripod', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1.1, + braceTopSpread: 1.1, + bracePlateEnabled: false, + }, + trestleSupport: { + label: 'Trestle Frame', + supportStyle: 'trestle', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1.2, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + portalFrameSupport: { + label: 'Portal Frame', + supportStyle: 'portal-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 1.4, + braceTopSpread: 0.2, + bracePlateEnabled: false, + }, + boxFrameSupport: { + label: 'Box Frame', + supportStyle: 'box-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1.4, + braceTopSpread: 1, + bracePlateEnabled: false, + }, +} as const satisfies Record>> + +export type ColumnPresetId = keyof typeof COLUMN_PRESETS + +export type ColumnNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/cupola.ts b/services/pascal-editor/packages/core/src/schema/nodes/cupola.ts new file mode 100644 index 000000000..6e8c12a79 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/cupola.ts @@ -0,0 +1,37 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const CupolaNode = BaseNode.extend({ + id: objectId('cupola'), + type: nodeType('cupola'), + + material: MaterialSchema.optional(), + // Default to the white preset so a freshly-placed cupola reads as clean + // painted metal and the paint inspector shows "White" (matches box-vent). + materialPreset: z.string().default('preset-white'), + + roofSegmentId: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + // Cupolas are larger than the other vents — a roof lantern, not a cap. + width: z.number().default(0.8), + depth: z.number().default(0.8), + height: z.number().default(1.0), + + // Roof cap shape: `dome` (round) or `pyramid` (four-sided point). + roofStyle: z.enum(['dome', 'pyramid']).default('dome'), + // Decorative post + ball at the apex. + finial: z.boolean().default(true), +}).describe( + dedent` + Cupola — a louvered roof lantern that sits astride a roof ridge or slope: + a louvered body topped by a dome or pyramid roof, optionally crowned with + a finial. Parented to a roof-segment; position is segment-local. Rotation + is around the segment's vertical axis (post-slope tilt). + `, +) + +export type CupolaNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/door.ts b/services/pascal-editor/packages/core/src/schema/nodes/door.ts new file mode 100644 index 000000000..26b4170e0 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/door.ts @@ -0,0 +1,141 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const DoorSegment = z.object({ + type: z.enum(['panel', 'glass', 'empty']), + heightRatio: z.number(), + + // Each segment controls its own column split + columnRatios: z.array(z.number()).default([1]), + dividerThickness: z.number().default(0.03), + + // panel-specific + panelDepth: z.number().default(0.01), // + raised, - recessed + panelInset: z.number().default(0.04), +}) + +export type DoorSegment = z.infer + +export const DoorCategory = z.enum(['interior', 'garage']) +export const DoorType = z.enum([ + 'hinged', + 'double', + 'french', + 'folding', + 'pocket', + 'barn', + 'sliding', + 'garage-sectional', + 'garage-rollup', + 'garage-tiltup', +]) +export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead']) + +export type DoorCategory = z.infer +export type DoorType = z.infer +export type DoorTrackStyle = z.infer + +export const DoorNode = BaseNode.extend({ + id: objectId('door'), + type: nodeType('door'), + material: MaterialSchema.optional(), + // Per-slot material overrides on the unified slot model. Keys: `panel` (the + // door body), `glass`. Value = a `MaterialRef` (`library:` / `scene:`). + // Absent = the body/glass default. Mirrors `ShelfNode.slots`. + slots: z.record(z.string(), z.string()).optional(), + + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + side: z.enum(['front', 'back']).optional(), + wallId: z.string().optional(), + // Alternative host: a roof-segment's generated wall face (base wall + // under the roof or a coplanar gable end). When set, `position` is + // FACE-LOCAL — [u along the face, v height, z from the wall mid-plane] + // — exactly the wall-child convention; the renderer mounts the node + // inside the face frame (`getRoofWallFaceFrame`), which is what makes + // hosted children track segment resizes live. + roofSegmentId: z.string().optional(), + roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), + + // Overall dimensions + width: z.number().default(0.9), + height: z.number().default(2.1), + + // Door family + doorCategory: DoorCategory.default('interior'), + doorType: DoorType.default('hinged'), + leafCount: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(1), + operationState: z.number().min(0).max(1).default(0), + slideDirection: z.enum(['left', 'right']).default('left'), + trackStyle: DoorTrackStyle.default('none'), + garagePanelCount: z.number().int().min(1).max(12).default(4), + + // Opening mode + openingKind: z.enum(['door', 'opening']).default('door'), + openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), + openingRadiusMode: z.enum(['all', 'individual']).default('all'), + openingTopRadii: z.tuple([z.number(), z.number()]).default([0.15, 0.15]), + cornerRadius: z.number().min(0).default(0.15), + archHeight: z.number().min(0).default(0.45), + openingRevealRadius: z.number().min(0).default(0.025), + + // Frame + frameThickness: z.number().default(0.05), + frameDepth: z.number().default(0.07), + threshold: z.boolean().default(true), + thresholdHeight: z.number().default(0.02), + + // Swing + hingesSide: z.enum(['left', 'right']).default('left'), + swingDirection: z.enum(['inward', 'outward']).default('inward'), + swingAngle: z + .number() + .min(0) + .max(Math.PI / 2) + .default(0), + + // Leaf segments — stacked top to bottom, each with its own column split + segments: z.array(DoorSegment).default([ + { + type: 'panel', + heightRatio: 0.4, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + { + type: 'panel', + heightRatio: 0.6, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + ]), + + // Handle + handle: z.boolean().default(true), + handleHeight: z.number().default(1.05), + handleSide: z.enum(['left', 'right']).default('right'), + + // Leaf inner margin — space between leaf edge and segment content area [x, y] + contentPadding: z.tuple([z.number(), z.number()]).default([0.04, 0.04]), + + // Emergency / commercial hardware + doorCloser: z.boolean().default(false), + panicBar: z.boolean().default(false), + panicBarHeight: z.number().default(1.0), +}).describe(dedent`Door node - a parametric door placed on a wall + - position: center of the door in wall-local coordinate system (Y = height/2, always at floor) + - doorCategory/doorType: explicit operation family, defaulting old doors to interior hinged + - openingKind/openingShape: hinged door or frameless wall opening shape + - segments: rows stacked top to bottom, each defining its own columnRatios + - type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed + - hingesSide/swingDirection/swingAngle: which way the door opens and how far it is currently open + - doorCloser/panicBar: commercial and emergency hardware options +`) + +export type DoorNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/dormer.ts b/services/pascal-editor/packages/core/src/schema/nodes/dormer.ts new file mode 100644 index 000000000..a5702caa7 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/dormer.ts @@ -0,0 +1,142 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' +import { RoofType } from './roof-segment' + +export type DormerSurfaceMaterialRole = 'top' | 'side' | 'wall' +export type DormerSurfaceMaterialSpec = { + material?: z.infer + materialPreset?: string +} + +/** + * Default dormer dimensions and window controls. Values match the + * legacy archive so existing scenes don't shift visually. + */ +export const DORMER_DEFAULTS = { + WIDTH: 1.21, + DEPTH: 1.55, + WALL_HEIGHT: 0, + ROOF_HEIGHT: 0.49, + WALL_SKIRT_HEIGHT: 2.73, + WINDOW_WIDTH: 0.76, + WINDOW_HEIGHT: 0.68, + WINDOW_OFFSET_X: 0.02, + WINDOW_OFFSET_Y: 0.99, + WINDOW_FRAME_THICKNESS: 0.05, + WINDOW_FRAME_DEPTH: 0.06, + WINDOW_COLUMNS: 3, + WINDOW_ROWS: 3, + WINDOW_DIVIDER_THICKNESS: 0.02, + WINDOW_ARCH_HEIGHT: 0.35, + WINDOW_CORNER_RADIUS: 0.15, + WINDOW_SILL_DEPTH: 0.08, + WINDOW_SILL_THICKNESS: 0.03, +} as const + +const DEFAULT_CORNER_RADII: [number, number, number, number] = [ + DORMER_DEFAULTS.WINDOW_CORNER_RADIUS, + DORMER_DEFAULTS.WINDOW_CORNER_RADIUS, + DORMER_DEFAULTS.WINDOW_CORNER_RADIUS, + DORMER_DEFAULTS.WINDOW_CORNER_RADIUS, +] + +export const DormerNode = BaseNode.extend({ + id: objectId('dormer'), + type: nodeType('dormer'), + + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + topMaterial: MaterialSchema.optional(), + topMaterialPreset: z.string().optional(), + sideMaterial: MaterialSchema.optional(), + sideMaterialPreset: z.string().optional(), + wallMaterial: MaterialSchema.optional(), + wallMaterialPreset: z.string().optional(), + + roofSegmentId: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + width: z.number().default(DORMER_DEFAULTS.WIDTH), + depth: z.number().default(DORMER_DEFAULTS.DEPTH), + height: z.number().default(DORMER_DEFAULTS.WALL_HEIGHT), + + roofType: RoofType.default('gable'), + roofHeight: z.number().default(DORMER_DEFAULTS.ROOF_HEIGHT), + + // Height of the hung wall (the "skirt") that extends below the eave + // into the host roof — this is the wall area the window opening is + // cut through. Larger values let the dormer host taller windows. + wallSkirtHeight: z.number().default(DORMER_DEFAULTS.WALL_SKIRT_HEIGHT), + + // Window is rendered as parametric geometry on the dormer's front + // face — not a child node. The fields below mirror the legacy panel + // controls; geometry beyond the simple opening box is deferred. + windowWidth: z.number().default(DORMER_DEFAULTS.WINDOW_WIDTH), + windowHeight: z.number().default(DORMER_DEFAULTS.WINDOW_HEIGHT), + windowOffsetX: z.number().default(DORMER_DEFAULTS.WINDOW_OFFSET_X), + windowOffsetY: z.number().default(DORMER_DEFAULTS.WINDOW_OFFSET_Y), + windowFrameThickness: z.number().default(DORMER_DEFAULTS.WINDOW_FRAME_THICKNESS), + windowFrameDepth: z.number().default(DORMER_DEFAULTS.WINDOW_FRAME_DEPTH), + windowColumns: z.number().int().min(1).max(8).default(DORMER_DEFAULTS.WINDOW_COLUMNS), + windowRows: z.number().int().min(1).max(8).default(DORMER_DEFAULTS.WINDOW_ROWS), + windowDividerThickness: z.number().default(DORMER_DEFAULTS.WINDOW_DIVIDER_THICKNESS), + windowShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), + windowArchHeight: z.number().default(DORMER_DEFAULTS.WINDOW_ARCH_HEIGHT), + // Single source of truth for the rounded-shape corner radii. Tuple is + // [topLeft, topRight, bottomRight, bottomLeft]. "All vs Individual" + // is a UI-only view mode derived from whether the tuple is uniform. + windowCornerRadii: z + .tuple([z.number(), z.number(), z.number(), z.number()]) + .default(DEFAULT_CORNER_RADII), + windowSill: z.boolean().default(false), + windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH), + windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS), +}).describe( + dedent` + Dormer — a small house-shaped protrusion sitting on top of a roof + segment. width × depth × height defines the box base; roofType and + roofHeight define the dormer's own roof shape. The window opening + is parametric geometry on the dormer's front face, not a hosted + child node. + `, +) + +export type DormerNode = z.infer + +/** + * Per-surface material resolution. Fall-through order: + * top → topMaterial[Preset] → legacy + * side → sideMaterial[Preset] → wallMaterial[Preset] → legacy + * wall → wallMaterial[Preset] → sideMaterial[Preset] → legacy + * where legacy is `node.material` / `node.materialPreset`. + */ +export function getEffectiveDormerSurfaceMaterial( + node: DormerNode, + role: DormerSurfaceMaterialRole, +): DormerSurfaceMaterialSpec { + const top: DormerSurfaceMaterialSpec = { + material: node.topMaterial, + materialPreset: node.topMaterialPreset, + } + const side: DormerSurfaceMaterialSpec = { + material: node.sideMaterial, + materialPreset: node.sideMaterialPreset, + } + const wall: DormerSurfaceMaterialSpec = { + material: node.wallMaterial, + materialPreset: node.wallMaterialPreset, + } + const legacy: DormerSurfaceMaterialSpec = { + material: node.material, + materialPreset: node.materialPreset, + } + const has = (spec: DormerSurfaceMaterialSpec) => + spec.material !== undefined || typeof spec.materialPreset === 'string' + + if (role === 'top') return has(top) ? top : legacy + if (role === 'side') return has(side) ? side : has(wall) ? wall : legacy + return has(wall) ? wall : has(side) ? side : legacy +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/downspout.ts b/services/pascal-editor/packages/core/src/schema/nodes/downspout.ts new file mode 100644 index 000000000..e3c1de8f1 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/downspout.ts @@ -0,0 +1,74 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const DownspoutNode = BaseNode.extend({ + id: objectId('downspout'), + type: nodeType('downspout'), + + material: MaterialSchema.optional(), + // Match the gutter family default — paint inspector reads "White" + // instead of "no material" on a freshly placed downspout. + materialPreset: z.string().default('preset-white'), + + // Logical attachment: the gutter this downspout drains. Scene-graph + // parent is the same roof-segment that hosts the gutter, so the + // renderer can be reached through the segment's children list (like + // every other roof accessory). gutterId then drives the LOOKUP of + // outlet X/Z/diameter — the downspout's actual mount position is + // derived from the gutter, not stored. + gutterId: z.string().optional(), + // Which of the host gutter's `outlets` this downspout drains, by the + // outlet's `id`. The mount position, bore, and cross-section shape are + // looked up from that outlet — so several downspouts on one gutter no + // longer stack on a single drop. + outletId: z.string().optional(), + + // Length the pipe extends DOWN from the gutter outlet, in metres. + // Default 2.5 m covers a typical residential storey; the placement + // tool can default to the gutter's eave-Y minus building floor on + // commit so the user doesn't have to set it on every drop. + length: z.number().default(2.5), + // Bore diameter, default 0.07 m ≈ 3″ to match the gutter outlet + // default. Larger downspouts are common on commercial gutters. + diameter: z.number().default(0.07), + // Gap between the pipe's wall-facing surface and the wall face, in + // metres. The downspout's offset elbows step it back from the eave + // overhang to the wall; this controls how far proud of the wall it + // sits (real downspouts mount on standoff brackets ~1–2 cm off the + // wall). Larger values pull the run back OUT toward the eave — the + // escape hatch when the auto-routed wall position doesn't match the + // actual wall (overshoots into it). Default 0.02 m. + standoff: z.number().default(0.02), + + // Cross-section shape. 'auto' follows the host gutter's profile (round + // on half-round, rectangular on k-style / box); 'round' / 'rect' + // override it for a mixed look. + shape: z.enum(['auto', 'round', 'rect']).default('auto'), + + // Wall straps clamping the run to the wall. 'band' renders periodic + // bands; 'none' hides them. Spacing is metres between bands. + strapStyle: z.enum(['band', 'none']).default('band'), + strapSpacing: z.number().default(1.8), + + // What happens at the bottom of the run: + // - 'splash': kickout elbow + a splash block on the ground (default) + // - 'kickout': kickout elbow only (e.g. into a drain pipe) + // - 'straight': no kick — runs straight down (into a grade drain) + terminal: z.enum(['splash', 'kickout', 'straight']).default('splash'), +}).describe( + dedent` + Downspout — a vertical pipe that takes water from a gutter outlet + down to ground level. Parented to a roof-segment (scene-graph), + linked to a specific gutter via gutterId for outlet position. + - length: vertical pipe length below the gutter outlet + - diameter: bore diameter; should match the host gutter's outletDiameter + - standoff: gap the pipe sits proud of the wall (pulls the run out toward the eave) + - shape: cross-section (auto follows the gutter profile, or force round / rect) + - strapStyle / strapSpacing: wall straps clamping the run + - terminal: bottom treatment (splash block, kickout only, or straight down) + `, +) + +export type DownspoutNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/duct-fitting.ts b/services/pascal-editor/packages/core/src/schema/nodes/duct-fitting.ts new file mode 100644 index 000000000..1c22a6e09 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/duct-fitting.ts @@ -0,0 +1,97 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Duct fitting — the junction pieces that connect round duct segments: + * elbows (direction change), tees (branch takeoff), reducers (diameter + * transition). + * + * Phase 2 of the HVAC node system. Fittings are the first kind to expose + * typed ports (`def.ports`) — placement tools snap duct endpoints onto a + * fitting's collars, and the future system graph walks ports to decide + * connectivity. + * + * `position` is level-local meters; `rotation` is an XYZ euler in radians + * so a fitting can turn a horizontal run vertical (riser elbows). + * + * Local-frame conventions (before `rotation` is applied): + * - elbow: inlet faces -X, outlet turned by `angle` degrees in the + * XZ plane (90° → +Z). + * - tee: run along the X axis (ports face -X and +X), branch + * collar at `branchAngle`° from the +X (outlet) axis in the + * XZ plane — 90° a square straight tee, <90° a lateral + * leaning downstream toward the outlet, >90° leaning upstream + * toward the inlet — sized at `diameter2`. + * - cross: four-way junction — run along the X axis (ports face -X + * and +X) at the run profile, two opposed branches square to + * the run along ±Z (branch faces +Z, branch2 faces -Z) at the + * branch profile (`shape2` / `diameter2`). + * - reducer: inlet at `diameter` faces -X, outlet at `diameter2` + * faces +X. + * - transition: square-to-round — rect end at `width` × `height` faces + * -X, round end at `diameter2` faces +X. `diameter` carries + * the rect end's area-equivalent round size. + */ +export const DuctFittingNode = BaseNode.extend({ + id: objectId('duct-fitting'), + type: nodeType('duct-fitting'), + // Level-local meters. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // XYZ euler radians. + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + fittingType: z.enum(['elbow', 'tee', 'cross', 'reducer', 'transition']).default('elbow'), + // Run-leg cross-section: round collars, or a rect / flat-oval profile + // matching the trunk the fitting sits in. Reducers ignore the shape. + // When non-round, `diameter` carries the area-equivalent round size + // (drives leg lengths + advertised ports). + shape: z.enum(['round', 'rect', 'oval']).default('rect'), + // Rect / oval run-leg profile in inches (used when shape ≠ 'round'). + width: z.number().min(4).max(60).default(14), + height: z.number().min(3).max(40).default(8), + // Tee / cross BRANCH cross-section: a round collar at `diameter2` or a + // rect / oval profile matching the duct drawn off the tap. When + // non-round, `diameter2` carries the branch's area-equivalent round + // size. A cross's two opposed branches share this one profile. + shape2: z.enum(['round', 'rect', 'oval']).default('rect'), + // Rect / oval branch profile in inches (used when shape2 ≠ 'round'). + width2: z.number().min(4).max(60).default(14), + height2: z.number().min(3).max(40).default(8), + // Elbow turn angle in degrees. Residential sheet-metal elbows come in + // 90° and 45°; adjustable elbows cover the range between. 0° is a + // straight coupling — what an elbow flattens to when its run is dragged + // into line with the fixed collar. + angle: z.number().min(0).max(90).default(90), + // Tee branch angle in degrees, measured off the +X (outlet) axis: 90° + // is a square straight tee, <90° a lateral whose branch sweeps + // downstream toward the outlet (flow merges), >90° leans the branch + // upstream toward the inlet. Ignored by every other fitting type. + branchAngle: z.number().min(45).max(135).default(90), + // Main (run/inlet) nominal diameter in inches. + diameter: z.number().min(2).max(48).default(6), + // Secondary diameter in inches — tee branch collar, reducer outlet. + // Ignored by elbows. + diameter2: z.number().min(2).max(48).default(6), + ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'), + system: z.enum(['supply', 'return']).default('supply'), + slots: z.record(z.string(), z.string()).optional(), +}).describe( + dedent` + Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs. + - position: [x, y, z] level-local meters + - rotation: [x, y, z] euler radians + - fittingType: elbow | tee | cross | reducer | transition (rect end -X, round end +X) + - shape: round | rect | oval run legs (matches the trunk; ignored by reducer / transition) + - width / height: rect / oval run-leg profile in inches (transition: the rect end) + - shape2: round | rect | oval tee / cross branch (matches the duct drawn off the tap) + - width2 / height2: rect / oval branch profile in inches + - angle: elbow turn in degrees (45 or 90 typical) + - branchAngle: tee branch angle off the outlet axis (90 straight tee, 45 downstream lateral, 135 upstream); cross branches are always square + - diameter: main nominal diameter in inches + - diameter2: tee / cross branch / reducer outlet / transition round-end diameter in inches + - ductMaterial: sheet-metal | flex | duct-board + - system: supply | return + `, +) +export type DuctFittingNode = z.infer +export type DuctFittingNodeId = DuctFittingNode['id'] diff --git a/services/pascal-editor/packages/core/src/schema/nodes/duct-segment.ts b/services/pascal-editor/packages/core/src/schema/nodes/duct-segment.ts new file mode 100644 index 000000000..348a909d9 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/duct-segment.ts @@ -0,0 +1,78 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Round duct segment — a polyline of 3D points connected by cylindrical + * duct sections. Forced-air HVAC supply/return runs in US residential. + * + * Phase 1 of the HVAC node system: just the geometry primitive. Fittings, + * terminals, equipment, and typed ports come in later slices. + * + * Path coordinates are level-local meters: [x, y, z] tuples. y is height + * above the level floor. A duct hung at ceiling height through three points + * is e.g. `[[0, 2.6, 0], [3, 2.6, 0], [3, 2.6, 4]]`. + * + * Diameters are nominal US round-duct sizes in inches; the geometry + * builder converts to meters for the cylinder radius. + */ +export const DuctSegmentNode = BaseNode.extend({ + id: objectId('duct-segment'), + type: nodeType('duct-segment'), + // Polyline path in level-local meters. Minimum two points (start, end). + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Cross-section. Round is the branch default; rect is the trunk / + // plenum profile (real US systems: rect trunk, round branches); oval + // is the flat-oval profile (two semicircles of the duct height joined + // by flat sides) used where round won't fit a joist bay. + shape: z.enum(['round', 'rect', 'oval']).default('round'), + // Nominal inner diameter in inches (round shape). Common residential + // sizes 4"–14"; we accept any positive number so the inspector slider + // stays ergonomic and larger commercial sizes load without a schema bump. + diameter: z.number().min(2).max(48).default(6), + // Rect / oval cross-section in inches: width is the horizontal face, + // height the vertical. Typical residential trunks 12×8 – 24×10. For + // oval, height is also the end-cap semicircle diameter (width ≥ height). + width: z.number().min(4).max(60).default(14), + height: z.number().min(3).max(40).default(8), + // Cross-section roll (radians) about the run direction. 0 = width + // horizontal / height vertical (the natural orientation the geometry + // derives from direction). Non-zero only on a rect riser turned out of + // the horizontal plane, so its profile stays continuous through the + // elbow it left instead of snapping to the world-axis fallback. + roll: z.number().default(0), + // Construction material. Spiral is round rigid sheet metal with the + // helical lock seam drawn on the body (round shape only — rect / oval + // runs render it as plain sheet metal). + ductMaterial: z.enum(['sheet-metal', 'spiral', 'flex', 'duct-board']).default('flex'), + // Whether to draw the construction body detail (spiral lock seam / + // flex wire corrugation) on round runs. Off renders a smooth body — + // lighter on the eyes and the GPU in dense scenes. + seamDetail: z.boolean().default(false), + // Whether the run wears its external insulation wrap (drawn as a + // translucent shell). Off by default — bare duct. + insulated: z.boolean().default(false), + // External insulation R-value (used when insulated). Common flex-duct + // values are R-4.2, R-6, R-8. + insulationR: z.number().min(0).max(12).default(0.5), + // Which side of the air loop this segment belongs to. Drives visual tint + // and (in later slices) System graph membership. + system: z.enum(['supply', 'return']).default('supply'), + slots: z.record(z.string(), z.string()).optional(), +}).describe( + dedent` + Duct segment - polyline of 3D points connected by duct sections. + - path: list of [x, y, z] points in level-local meters (min 2) + - shape: round (branches) | rect (trunks / plenums) | oval (flat-oval, tight joist bays) + - diameter: nominal inner diameter in inches for round (typ. 4-14 residential) + - width / height: rect / oval cross-section in inches (typ. 12x8 - 24x10 trunks) + - roll: cross-section roll in radians (0 = upright; set on risers to stay continuous through their elbow) + - ductMaterial: sheet-metal | spiral (round rigid, helical seam) | flex | duct-board + - seamDetail: draw the spiral seam / flex corrugation on round runs (default off) + - insulated: whether the run wears its external insulation wrap (default off) + - insulationR: external insulation R-value when insulated (4, 6, 8 typical) + - system: supply | return (drives visual tint) + `, +) +export type DuctSegmentNode = z.infer +export type DuctSegmentNodeId = DuctSegmentNode['id'] diff --git a/services/pascal-editor/packages/core/src/schema/nodes/duct-terminal.ts b/services/pascal-editor/packages/core/src/schema/nodes/duct-terminal.ts new file mode 100644 index 000000000..5eefff314 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/duct-terminal.ts @@ -0,0 +1,56 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Duct terminal — where the air loop meets the room: supply registers, + * ceiling diffusers, return grilles. + * + * Phase 3 of the HVAC node system. Each terminal exposes a single typed + * port at its collar (behind/above/below the face depending on mount), + * so duct runs end onto it like any other port. + * + * `position` is the center of the visible face in level-local meters — + * floor registers at y≈0, ceiling diffusers at ceiling height, wall + * registers at their height on the wall. `rotation` is yaw radians. + */ +export const DuctTerminalNode = BaseNode.extend({ + id: objectId('duct-terminal'), + type: nodeType('duct-terminal'), + // Level-local meters — center of the face. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Yaw in radians. + rotation: z.number().default(0), + terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'), + // Which surface the terminal mounts on. Drives face orientation and + // which way the collar (and its port) points. + mount: z.enum(['floor', 'ceiling', 'wall']).default('floor'), + // Face dimensions in meters. Typical floor register ~0.30 × 0.15; + // ceiling diffusers are square (0.6 × 0.6); return grilles run large. + width: z.number().min(0.1).max(1.5).default(0.3), + depth: z.number().min(0.05).max(1.5).default(0.15), + // Collar cross-section on the duct side. Round is the default; rect and + // oval (flat-oval) match the duct shapes a run might end with. + collarShape: z.enum(['round', 'rect', 'oval']).default('round'), + // Round collar diameter in inches on the duct side. + collarDiameter: z.number().min(4).max(20).default(6), + // Rect / oval collar cross-section in inches: width is the horizontal + // face, height the vertical. For oval, height is also the end-cap + // semicircle diameter (width ≥ height). + collarWidth: z.number().min(4).max(20).default(10), + collarHeight: z.number().min(3).max(20).default(6), +}).describe( + dedent` + Duct terminal - supply register, ceiling diffuser, or return grille. + - position: [x, y, z] level-local meters, center of the face + - rotation: yaw radians + - terminalType: supply-register | diffuser | return-grille (grille = return side) + - mount: floor | ceiling | wall - face orientation + collar direction + - width / depth: face size in meters + - collarShape: round | rect | oval - duct-side collar cross-section + - collarDiameter: round collar diameter in inches + - collarWidth / collarHeight: rect / oval collar cross-section in inches + `, +) +export type DuctTerminalNode = z.infer +export type DuctTerminalNodeId = DuctTerminalNode['id'] diff --git a/services/pascal-editor/packages/core/src/schema/nodes/elevator.ts b/services/pascal-editor/packages/core/src/schema/nodes/elevator.ts new file mode 100644 index 000000000..d1c97b491 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/elevator.ts @@ -0,0 +1,67 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ElevatorDoorStyle = z.enum(['center-opening', 'single-left', 'single-right']) +export const ElevatorDoorPanelStyle = z.enum(['glass-frame', 'solid-panel', 'segmented-panel']) +export const ElevatorShaftStyle = z.enum(['solid', 'glass']) + +export type ElevatorDoorPanelStyle = z.infer +export type ElevatorDoorStyle = z.infer +export type ElevatorShaftStyle = z.infer + +export const ElevatorNode = BaseNode.extend({ + id: objectId('elevator'), + type: nodeType('elevator'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + // Unified paint-slot refs (`scene:`/`library:` MaterialRef per slot id), + // matching the slot model items/slab/shelf use. Absent = declared default. + slots: z.record(z.string(), z.string()).optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around the Y axis in radians. + rotation: z.number().default(0), + width: z.number().default(1.84), + depth: z.number().default(1.84), + shaftWidth: z.number().optional(), + shaftDepth: z.number().optional(), + shaftWallThickness: z.number().default(0.09), + shaftStyle: ElevatorShaftStyle.default('solid'), + cabHeight: z.number().default(2.35), + doorWidth: z.number().default(0.95), + doorHeight: z.number().default(2.1), + doorStyle: ElevatorDoorStyle.default('center-opening'), + doorPanelStyle: ElevatorDoorPanelStyle.default('glass-frame'), + fromLevelId: z.string().nullable().default(null), + toLevelId: z.string().nullable().default(null), + servedLevelIds: z.array(z.string()).optional(), + disabledLevelIds: z.array(z.string()).default([]), + serviceOnlyLevelIds: z.array(z.string()).default([]), + defaultLevelId: z.string().nullable().default(null), + speed: z.number().default(2.2), + doorDurationMs: z.number().default(900), + dwellMs: z.number().default(1400), +}).describe( + dedent` + Elevator node - a vertical transport core attached to a building. + - parentId: building that owns this elevator + - position: building-local shaft center on the X/Z plane + - rotation: rotation around the Y axis + - width/depth: cab footprint + - shaftWidth/shaftDepth: optional clear shaft footprint; falls back to cab footprint + - shaftWallThickness: visible shaft shell thickness + - shaftStyle: solid or glass shaft shell presentation + - cabHeight: visible elevator cab height + - doorWidth/doorHeight/doorStyle: landing and cab door movement/opening presentation + - doorPanelStyle: visual leaf style for glass-frame, solid-panel, or segmented-panel doors + - fromLevelId / toLevelId: source and destination levels used for service range and auto cutouts + - servedLevelIds: legacy optional explicit level list; used only when from/to are missing + - disabledLevelIds: stops visible in the service range but unavailable for public/cab requests + - serviceOnlyLevelIds: stops unavailable from landing calls but available from cab/admin controls + - defaultLevelId: starting/resting level, falling back to the lowest served level + - speed/doorDurationMs/dwellMs: runtime animation defaults + `, +) + +export type ElevatorNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/eyebrow-vent.ts b/services/pascal-editor/packages/core/src/schema/nodes/eyebrow-vent.ts new file mode 100644 index 000000000..0797882fe --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/eyebrow-vent.ts @@ -0,0 +1,51 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const EyebrowVentNode = BaseNode.extend({ + id: objectId('eyebrow-vent'), + type: nodeType('eyebrow-vent'), + + material: MaterialSchema.optional(), + // Default to the white preset so a freshly-placed vent reads as clean + // painted metal and the paint inspector shows "White" (matches box-vent). + materialPreset: z.string().default('preset-white'), + + roofSegmentId: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + // `width` is the opening width (left↔right across the louvered face), + // `depth` the front-to-back projection along the roof, `height` the rise. + width: z.number().default(0.5), + depth: z.number().default(0.6), + height: z.number().default(0.3), + + // Body shape: + // - `scoop` : rounded louvered opening that sweeps back and tapers + // into the flashing plate (the classic dormer "eyebrow"). + // - `half-round` : a D-shaped half-round louver vent (flat louvered face, + // semicircular curved top, short body). + // - `slant-box` : a low box with a slanted top and a screened front. + style: z.enum(['scoop', 'half-round', 'slant-box']).default('scoop'), + + // Number of horizontal louver slats across the front opening (0 = open / + // screened only). + louverCount: z.number().int().min(0).max(8).default(3), + + // `slant-box` only: the low rear edge as a fraction of the tall front edge. + // 1 = flat-topped box, lower = the top slopes down more steeply toward the + // back. + backRatio: z.number().min(0.15).max(1).default(0.5), +}).describe( + dedent` + Eyebrow vent — a low roof ventilator with a rounded, louvered front that + sits on a flashing plate. Three styles: a swept "scoop" eyebrow, a D-shaped + "half-round" louver vent, and a "slant-box" hood. Parented to a roof-segment; + position is segment-local. Rotation is around the segment's vertical axis + (post-slope tilt). + `, +) + +export type EyebrowVentNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/fence.ts b/services/pascal-editor/packages/core/src/schema/nodes/fence.ts new file mode 100644 index 000000000..e8e768742 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/fence.ts @@ -0,0 +1,65 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const FenceStyle = z.enum(['slat', 'rail', 'privacy', 'horizontal']) +export const FenceBaseStyle = z.enum(['floating', 'grounded']) +export const FencePostCap = z.enum(['none', 'flat', 'pyramid']) + +export const FenceNode = BaseNode.extend({ + id: objectId('fence'), + type: nodeType('fence'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + // Unified paint-slot refs (`scene:`/`library:` MaterialRef per slot id), + // matching the slot model items/slab/shelf use. Absent = declared default. + slots: z.record(z.string(), z.string()).optional(), + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + // Optional spline control points in level coordinate meters. When present + // (>= 2 points) the fence centerline is a smooth Catmull-Rom curve through + // these points and start/end/curveOffset no longer define the centerline. + // start/end are kept in sync with the first/last path point so consumers + // that read endpoints (handles, bbox, miter references) stay valid. Absent = + // the straight or single-arc fence defined by start/end (+ curveOffset). + path: z.array(z.tuple([z.number(), z.number()])).optional(), + // Optional per-control-point tangent handles, parallel to `path` (same + // length when present). Each entry is the OUT-handle offset vector [dx, dy] + // from its path point, in level meters; the IN handle is its mirror so the + // curve stays smooth through the point. `null` = use the automatic + // Catmull-Rom tangent for that point. Only meaningful for spline fences. + tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(), + height: z.number().default(1.8), + thickness: z.number().default(0.08), + curveOffset: z.number().optional(), + baseHeight: z.number().default(0.22), + postSpacing: z.number().default(2), + postSize: z.number().default(0.1), + topRailHeight: z.number().default(0.04), + groundClearance: z.number().default(0), + edgeInset: z.number().default(0.015), + // Reveal between the boards of a `horizontal` fence (0 = flush cladding). + slatGap: z.number().default(0.01), + // Topper drawn on each `horizontal`-fence post. + postCap: FencePostCap.default('pyramid'), + baseStyle: FenceBaseStyle.default('grounded'), + showInfill: z.boolean().default(true), + color: z.string().default('#ffffff'), + style: FenceStyle.default('slat'), +}).describe( + dedent` + Fence node - used to represent a fence segment in the building/site level coordinate system + - start/end: fence endpoints in level coordinate system + - path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them + - tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent + - height/thickness: overall fence dimensions in meters + - curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set) + - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model + - groundClearance/edgeInset/baseStyle: fence support and inset configuration + - showInfill: whether to draw intermediate posts/slats between end posts + - color/style: visual appearance options + `, +) + +export type FenceNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/guide.ts b/services/pascal-editor/packages/core/src/schema/nodes/guide.ts new file mode 100644 index 000000000..8dafffb20 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/guide.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { AssetUrl } from '../asset-url' +import { BaseNode, nodeType, objectId } from '../base' + +export const GuideScaleReference = z.object({ + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + realLengthMeters: z.number().positive(), + measuredLengthUnits: z.number().positive(), + metersPerUnit: z.number().positive(), + label: z.string(), +}) + +export const GuideNode = BaseNode.extend({ + id: objectId('guide'), + type: nodeType('guide'), + url: AssetUrl, + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + scale: z.number().default(1), + opacity: z.number().min(0).max(100).default(50), + scaleReference: GuideScaleReference.nullable().default(null), +}) + +export type GuideScaleReference = z.infer +export type GuideNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/gutter.ts b/services/pascal-editor/packages/core/src/schema/nodes/gutter.ts new file mode 100644 index 000000000..d9fbc7e44 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/gutter.ts @@ -0,0 +1,90 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +// A single drop outlet drilled in the gutter floor. A gutter can carry +// several so a long run can split between multiple downspouts (each +// downspout links to one outlet via its `outletId`). +export const GutterOutlet = z.object({ + // Stable id the downspout references. Generated with `generateId('outlet')`. + id: z.string(), + // Position along the gutter length (gutter-local +X), signed from the + // CENTER. The geometry clamps it inside the end caps at build time, so + // a stored value that no longer fits just rides the nearest bound. + offset: z.number().default(0), + // Bore diameter of this drop. Default 0.07 m ≈ 3″. The cross-section + // SHAPE (round vs rectangular) follows the gutter's profile, not this. + diameter: z.number().default(0.07), +}) +export type GutterOutlet = z.infer + +export const GutterNode = BaseNode.extend({ + id: objectId('gutter'), + type: nodeType('gutter'), + + material: MaterialSchema.optional(), + // White preset by default — matches the rest of the roof accessory + // family (box-vent / ridge-vent) so the paint inspector reads as + // "White" instead of "no material" on a freshly-placed gutter. + materialPreset: z.string().default('preset-white'), + + roofSegmentId: z.string().optional(), + // Segment-local. The placement tool snaps to the eave line (Z = + // +depth/2, Y = wallHeight) of the segment under the cursor; X is + // wherever the user clicked. After placement the inspector + length + // handles can shift X along the eave. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around the gutter's own local Y. Kept at 0 by default + // because the gutter's length axis is constrained to the eave + // direction (segment-local +X) — but exposed in case the user wants + // to tilt for a custom run. + rotation: z.number().default(0), + + // Length along the eave (gutter-local +X). + length: z.number().default(2.0), + // Profile size — the vertical drop of the U-channel below the eave + // line. 5″ (0.127 m) is the most common residential gutter size; 6″ + // (0.152 m) is the common commercial / heavy-duty size. Default + // rounds the residential value to 0.13 m. + size: z.number().default(0.13), + // Wall thickness of the U-channel. Visible on the rim from above; too + // thin reads as a paper strip, too thick reads as a curb. + thickness: z.number().default(0.006), + + profile: z.enum(['k-style', 'half-round', 'box']).default('k-style'), + + // End caps close the open ends of the U-channel so water can't run + // out the sides. Independent per-end because a downspout typically + // joins the gutter at one end while the other stays capped. Default + // true on both — matches a freshly-installed residential gutter. + endCapLeft: z.boolean().default(true), + endCapRight: z.boolean().default(true), + + // Hangers are the metal straps that hold the gutter onto the + // fascia. 'strap' renders periodic bars across the rim; 'none' + // hides them (some plastic gutters use hidden clips). Spacing is + // metres between hanger centers; real residential code is roughly + // 0.6 m for snow-load areas, 0.75 m elsewhere. + hangerStyle: z.enum(['strap', 'none']).default('strap'), + hangerSpacing: z.number().default(0.6), + + // Downspout outlets — short drop tubes descending from the gutter + // floor where downspouts connect. Empty by default so existing + // gutters don't sprout outlets on schema upgrade. Each is drilled + // through the trough floor via CSG; a downspout links to one by id. + outlets: z.array(GutterOutlet).default([]), +}).describe( + dedent` + Gutter — a rain-water channel running along the eave of a roof + segment. Parented to a roof-segment; position is segment-local. + - length: span along the eave (gutter-local +X) + - size: profile drop below the eave line (vertical extent) + - profile: k-style (ogee fascia), half-round, or square box + - endCapLeft / endCapRight: close the trough at gutter-local -X / +X + - hangerStyle / hangerSpacing: visible metal straps across the rim + - outlets: drop-tube outlets (id + along-length offset + bore diameter) + `, +) + +export type GutterNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/hvac-equipment.ts b/services/pascal-editor/packages/core/src/schema/nodes/hvac-equipment.ts new file mode 100644 index 000000000..a0e00a139 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/hvac-equipment.ts @@ -0,0 +1,60 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * HVAC equipment — the boxes duct systems start and end at: furnace, + * air handler, outdoor condenser. + * + * Phase 3 of the HVAC node system. Furnaces and air handlers expose + * typed duct ports (supply plenum on top, return drop on the side) so + * duct runs and fittings snap onto them. Every unit also exposes a + * refrigerant service port on its valve face — a condenser, the outdoor + * half of a split system, carries no duct ports but pipes to the indoor + * coil through a `lineset` run mating onto that port. + * + * Floor-placed: `position` is level-local meters with y at the base, + * `rotation` is yaw radians (the editor's default R-rotate applies). + */ +export const HvacEquipmentNode = BaseNode.extend({ + id: objectId('hvac-equipment'), + type: nodeType('hvac-equipment'), + // Level-local meters, y at the unit's base. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Yaw in radians. + rotation: z.number().default(0), + equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'), + // Cabinet dimensions in meters. Defaults match a typical upflow + // furnace cabinet (~22" × 28" footprint, ~43" tall). + width: z.number().min(0.3).max(2).default(0.56), + depth: z.number().min(0.3).max(2).default(0.71), + height: z.number().min(0.4).max(2.5).default(1.1), + // Duct collar cross-section on the supply / return connections. Round is + // the default; rect and oval (flat-oval) match the duct shapes a run + // might mate with. Condensers carry no duct collars (ignored). + supplyShape: z.enum(['round', 'rect', 'oval']).default('round'), + returnShape: z.enum(['round', 'rect', 'oval']).default('round'), + // Round collar diameters in inches. + supplyDiameter: z.number().min(6).max(30).default(8), + returnDiameter: z.number().min(6).max(30).default(8), + // Rect / oval collar cross-section in inches: width is the horizontal + // face, height the vertical. For oval, height is also the end-cap + // semicircle diameter (width ≥ height). + supplyWidth: z.number().min(6).max(30).default(12), + supplyHeight: z.number().min(6).max(30).default(8), + returnWidth: z.number().min(6).max(30).default(14), + returnHeight: z.number().min(6).max(30).default(8), +}).describe( + dedent` + HVAC equipment cabinet - furnace, air handler, or outdoor condenser. + - position: [x, y, z] level-local meters (y = base) + - rotation: yaw radians + - equipmentType: furnace | air-handler | condenser + - width / depth / height: cabinet size in meters + - supplyShape / returnShape: round | rect | oval duct collar cross-section (ignored by condenser) + - supplyDiameter / returnDiameter: round collar sizes in inches + - supplyWidth / supplyHeight / returnWidth / returnHeight: rect / oval collar cross-section in inches + `, +) +export type HvacEquipmentNode = z.infer +export type HvacEquipmentNodeId = HvacEquipmentNode['id'] diff --git a/services/pascal-editor/packages/core/src/schema/nodes/item.ts b/services/pascal-editor/packages/core/src/schema/nodes/item.ts new file mode 100644 index 000000000..f8732482b --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/item.ts @@ -0,0 +1,193 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { AssetUrl } from '../asset-url' +import { BaseNode, nodeType, objectId } from '../base' +import type { CollectionId } from '../collections' + +// --- Control descriptors --- + +const toggleControlSchema = z.object({ + kind: z.literal('toggle'), + label: z.string().optional(), + default: z.boolean().optional(), +}) + +const sliderControlSchema = z.object({ + kind: z.literal('slider'), + label: z.string(), + min: z.number(), + max: z.number(), + step: z.number().default(1), + unit: z.string().optional(), + displayMode: z.enum(['slider', 'stepper', 'dial']).default('slider'), + default: z.number().optional(), +}) + +const temperatureControlSchema = z.object({ + kind: z.literal('temperature'), + label: z.string().default('Temperature'), + min: z.number().default(16), + max: z.number().default(30), + unit: z.enum(['C', 'F']).default('C'), + default: z.number().optional(), +}) + +const controlSchema = z.discriminatedUnion('kind', [ + toggleControlSchema, + sliderControlSchema, + temperatureControlSchema, +]) + +// --- Effect descriptors --- + +const animationEffectSchema = z.object({ + kind: z.literal('animation'), + clips: z.object({ + on: z.string().optional(), + off: z.string().optional(), + loop: z.string().optional(), + }), +}) + +const lightEffectSchema = z.object({ + kind: z.literal('light'), + color: z.string().default('#ffffff'), + intensityRange: z.tuple([z.number(), z.number()]), + distance: z.number().optional(), + offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), +}) + +const effectSchema = z.discriminatedUnion('kind', [animationEffectSchema, lightEffectSchema]) + +// --- Interactive descriptor --- + +const interactiveSchema = z.object({ + controls: z.array(controlSchema).default([]), + effects: z.array(effectSchema).default([]), +}) + +export type ToggleControl = z.infer +export type SliderControl = z.infer +export type TemperatureControl = z.infer +export type Control = z.infer +export type AnimationEffect = z.infer +export type LightEffect = z.infer +export type Effect = z.infer +export type Interactive = z.infer + +const assetSchema = z.object({ + id: z.string(), + category: z.string(), + name: z.string(), + thumbnail: z.string(), + // Optional top-down 2D image shown inside the item's footprint on the + // floor plan. When present, replaces the default diagonal-cross marker. + floorPlanUrl: z.string().optional(), + // Where the item came from in the catalog. Used by the editor's items + // panel to filter Library / Community / Mine. The server populates it + // from `items.userId`: null → 'library', current user → 'mine', + // other user → 'community'. Defaults to 'library' when absent (e.g. + // the seeded built-in catalog). + source: z.enum(['library', 'community', 'mine']).default('library'), + // True when the item belongs to the caller and is still in draft status. + // The catalog only loads my drafts (other users' drafts are never + // published to the catalog). Used so the Community filter can include + // *my* published items alongside other users', while leaving drafts + // visible only under Mine. + isDraft: z.boolean().optional(), + src: AssetUrl, + dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] + attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), + // Ceiling fixtures (e.g. recessed downlights) that embed *into* the ceiling + // rather than hang below it: the item seats flush with the ceiling plane + // (its body rising into the void above) and the ceiling is cut out around + // the item's footprint. Ignored unless `attachTo === 'ceiling'`. + recessed: z.boolean().optional(), + tags: z.array(z.string()).optional(), + // Function-axis tag slugs from the taxonomy. Drives the hierarchical + // Items-tab browse: a tree node matches when any of its descendant slugs + // appears here. Absent for the seeded built-in catalog. + functionTags: z.array(z.string()).optional(), + // These are "Corrective" transforms to normalize the GLB + offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), + surface: z + .object({ + height: z.number(), // where things rest + }) + .optional(), // undefined = can't place things on it + interactive: interactiveSchema.optional(), +}) + +export type AssetInput = z.input +export type Asset = z.infer + +export const ItemNode = BaseNode.extend({ + id: objectId('item'), + type: nodeType('item'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), + side: z.enum(['front', 'back']).optional(), + children: z.array(objectId('item')).default([]), + + // Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side") + wallId: z.string().optional(), + wallT: z.number().optional(), // 0-1 parametric position along wall + // Alternative wall host: a roof-segment's generated wall face. When + // set, `position` is FACE-LOCAL — [u along the face, v = bottom edge, + // z from the wall mid-plane] — exactly the wall-child convention + // (ItemSystem's wall-side push applies the same way); the renderer + // mounts the node inside the face frame (`getRoofWallFaceFrame`). + roofSegmentId: z.string().optional(), + roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), + + // Denormalized references to collections this node belongs to + collectionIds: z.array(z.custom()).optional(), + + // Per-slot material overrides. Key = slot id (see deriveSlotId), value = a + // MaterialRef string ('library:' or 'scene:'). Absent = authored / + // registry default. A dangling ref renders the default (never blocks). + slots: z.record(z.string(), z.string()).optional(), + + asset: assetSchema, +}).describe(dedent`Item node - used to represent a item in the building + - position: position in level coordinate system (or parent coordinate system if attached) + - rotation: rotation in level coordinate system (or parent coordinate system if attached) + - asset: asset data + - category: category of the item + - dimensions: size in level coordinate system + - src: url of the model + - attachTo: where to attach the item (wall, wall-side, ceiling) + - offset: corrective position offset for the model + - rotation: corrective rotation for the model + - scale: corrective scale for the model + - tags: tags associated with the item +`) + +export type ItemNode = z.infer + +export const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1 + +/** + * Low, floor-resting items like rugs and parking mats can receive items visually, + * but should not become item parents or block normal floor placement. + */ +export function isLowProfileItemSurface(item: ItemNode): boolean { + if (item.asset.attachTo) return false + const surfaceHeight = item.asset.surface + ? item.asset.surface.height * item.scale[1] + : getScaledDimensions(item)[1] + return surfaceHeight <= LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT +} + +/** + * Returns the effective world-space dimensions of an item after applying its scale. + * Use this everywhere item.asset.dimensions is used for spatial calculations. + */ +export function getScaledDimensions(item: ItemNode): [number, number, number] { + const [w, h, d] = item.asset.dimensions + const [sx, sy, sz] = item.scale + return [w * sx, h * sy, d * sz] +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/level.ts b/services/pascal-editor/packages/core/src/schema/nodes/level.ts new file mode 100644 index 000000000..d0bd589de --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/level.ts @@ -0,0 +1,50 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { CeilingNode } from './ceiling' +import { ColumnNode } from './column' +import { FenceNode } from './fence' +import { GuideNode } from './guide' +import { ItemNode } from './item' +import { RoofNode } from './roof' +import { ScanNode } from './scan' +import { ShelfNode } from './shelf' +import { SlabNode } from './slab' +import { SpawnNode } from './spawn' +import { StairNode } from './stair' +import { WallNode } from './wall' +import { ZoneNode } from './zone' + +export const LevelNode = BaseNode.extend({ + id: objectId('level'), + type: nodeType('level'), + children: z + .array( + z.union([ + WallNode.shape.id, + FenceNode.shape.id, + ColumnNode.shape.id, + ItemNode.shape.id, + ZoneNode.shape.id, + SlabNode.shape.id, + CeilingNode.shape.id, + RoofNode.shape.id, + StairNode.shape.id, + ScanNode.shape.id, + GuideNode.shape.id, + SpawnNode.shape.id, + ShelfNode.shape.id, + ]), + ) + .default([]), + // Specific props + level: z.number().default(0), +}).describe( + dedent` + Level node - used to represent a level in the building + - children: array of floor, wall, ceiling, roof, item nodes + - level: level number + `, +) + +export type LevelNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/lineset.ts b/services/pascal-editor/packages/core/src/schema/nodes/lineset.ts new file mode 100644 index 000000000..06bc7ff3a --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/lineset.ts @@ -0,0 +1,43 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Refrigerant lineset — the copper pipe pair that links the outdoor + * condenser to the indoor coil (furnace / air handler) of a split system. + * It is the refrigerant-side analogue of a duct run: a polyline of points, + * but carrying two lines instead of one airway. + * + * Real linesets run a fat insulated SUCTION line (cool vapour back to the + * compressor) beside a thin bare LIQUID line (warm liquid out to the coil). + * The geometry builder draws a single copper line on the path centerline + * (sized to `suctionDiameter`, wrapped in a foam jacket when `insulated`); + * draw the liquid line as a second lineset rather than both off one path. + * + * Path coordinates are level-local meters: [x, y, z] tuples, same space as + * duct paths and grid events. Diameters are nominal copper OD in inches. + */ +export const LinesetNode = BaseNode.extend({ + id: objectId('lineset'), + type: nodeType('lineset'), + // Polyline path in level-local meters. Minimum two points (start, end). + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Nominal suction-line copper OD in inches (the large insulated line). + // Common residential sizes are 3/4"–1-1/8". + suctionDiameter: z.number().min(0.25).max(2).default(0.875), + // Nominal liquid-line copper OD in inches (the small bare line). + // Common residential sizes are 1/4"–3/8". + liquidDiameter: z.number().min(0.125).max(1).default(0.375), + // Whether the suction line carries its foam insulation jacket. Bare = false. + insulated: z.boolean().default(true), +}).describe( + dedent` + Refrigerant lineset - copper suction + liquid pair linking a condenser to an indoor coil. + - path: list of [x, y, z] points in level-local meters (min 2) + - suctionDiameter: nominal copper OD in inches of the large insulated line (typ. 3/4"-1-1/8") + - liquidDiameter: nominal copper OD in inches of the small bare line (typ. 1/4"-3/8") + - insulated: whether the suction line wears its foam jacket + `, +) +export type LinesetNode = z.infer +export type LinesetNodeId = LinesetNode['id'] diff --git a/services/pascal-editor/packages/core/src/schema/nodes/liquid-line.ts b/services/pascal-editor/packages/core/src/schema/nodes/liquid-line.ts new file mode 100644 index 000000000..5b3f8b386 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/liquid-line.ts @@ -0,0 +1,29 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Standalone refrigerant liquid line — the thin bare-copper line that carries + * warm liquid out to the indoor coil. It is the line that used to be drawn as + * the lineset's second rail; broken out here as its own polyline run so it can + * be drawn on its own, including traced alongside an existing lineset. + * + * Path coordinates are level-local meters: [x, y, z] tuples, the same space as + * lineset and duct paths. Diameter is nominal copper OD in inches. + */ +export const LiquidLineNode = BaseNode.extend({ + id: objectId('liquid-line'), + type: nodeType('liquid-line'), + // Polyline path in level-local meters. Minimum two points (start, end). + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Nominal copper OD in inches. Common residential sizes are 1/4"–3/8". + diameter: z.number().min(0.125).max(1).default(0.375), +}).describe( + dedent` + Standalone refrigerant liquid line - a thin bare-copper polyline run. + - path: list of [x, y, z] points in level-local meters (min 2) + - diameter: nominal copper OD in inches (typ. 1/4"-3/8") + `, +) +export type LiquidLineNode = z.infer +export type LiquidLineNodeId = LiquidLineNode['id'] diff --git a/services/pascal-editor/packages/core/src/schema/nodes/pipe-fitting.ts b/services/pascal-editor/packages/core/src/schema/nodes/pipe-fitting.ts new file mode 100644 index 000000000..81707ae0a --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/pipe-fitting.ts @@ -0,0 +1,50 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * DWV pipe fitting — the joints drain systems are actually built from: + * elbows (bends), wyes (45° branch entries, the code-preferred way to + * join horizontal drains), sanitary tees (square branch entries), and + * crosses (two opposed branches where a run passes straight through). + * + * Local-frame conventions (before `rotation`): + * - elbow: inlet faces -X, outlet turned `angle`° in XZ. + * - wye: run along X (inlet -X, outlet +X), branch collar at + * 45° between +X and +Z. + * - sanitary-tee: run along X, branch collar faces +Z. + * - cross: run along X, two opposed branch collars on ±Z. + */ +export const PipeFittingNode = BaseNode.extend({ + id: objectId('pipe-fitting'), + type: nodeType('pipe-fitting'), + // Level-local meters. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // XYZ euler radians. + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + fittingType: z.enum(['elbow', 'wye', 'sanitary-tee', 'cross']).default('elbow'), + // Elbow turn in degrees — DWV bends ship as 22.5 / 45 / 90 ("long + // sweep" for drains); adjustable range matches the duct elbow. 0° is a + // straight coupling — what an elbow flattens to when its run is dragged + // into line with the fixed collar. + angle: z.number().min(0).max(90).default(90), + // Run nominal size in inches. + diameter: z.number().min(1.25).max(8).default(2), + // Branch collar size (wye / sanitary-tee). + diameter2: z.number().min(1.25).max(8).default(2), + pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), + system: z.enum(['waste', 'vent']).default('waste'), +}).describe( + dedent` + DWV pipe fitting - elbow (bend), wye (45° branch), sanitary tee (square branch), or cross (two opposed branches). + - position: [x, y, z] level-local meters + - rotation: [x, y, z] euler radians + - fittingType: elbow | wye | sanitary-tee | cross + - angle: elbow turn in degrees (22.5 / 45 / 90 typical) + - diameter: run size in inches; diameter2: branch collar size (both branches for a cross) + - pipeMaterial: pvc | abs | cast-iron + - system: waste | vent + `, +) +export type PipeFittingNode = z.infer +export type PipeFittingNodeId = PipeFittingNode['id'] diff --git a/services/pascal-editor/packages/core/src/schema/nodes/pipe-segment.ts b/services/pascal-editor/packages/core/src/schema/nodes/pipe-segment.ts new file mode 100644 index 000000000..6f7512ec3 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/pipe-segment.ts @@ -0,0 +1,41 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * DWV pipe segment — drain / waste / vent runs in US residential + * plumbing. Phase 2 of the distribution-system effort: the plumbing + * sibling of `duct-segment`, sharing the polyline model and the typed + * port machinery. + * + * The defining difference from ducts is SLOPE: drains must fall + * (IPC: ¼" per foot for pipes under 3", ⅛" allowed at 3"+). Slope is + * stored implicitly in the path's Y coordinates — the draw tool drops + * Y as you draw a waste run; vents run level or vertical. + * + * Path coordinates are level-local meters. Y may be negative (drains + * drop below the floor into the joist / crawl space). + */ +export const PipeSegmentNode = BaseNode.extend({ + id: objectId('pipe-segment'), + type: nodeType('pipe-segment'), + // Polyline path in level-local meters. Minimum two points. + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Nominal pipe size in inches. Residential DWV: 1¼ (lav tailpiece) to + // 4 (building drain); 6 covers oversized mains. + diameter: z.number().min(1.25).max(8).default(2), + pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), + // Which DWV role the run plays. Waste carries water (sloped); vent + // carries air (level or vertical, dashed in plan). + system: z.enum(['waste', 'vent']).default('waste'), +}).describe( + dedent` + DWV pipe segment - drain / waste / vent run as a polyline of 3D points. + - path: list of [x, y, z] points in level-local meters (min 2; y may go below the floor) + - diameter: nominal size in inches (1.5 / 2 / 3 / 4 typical residential) + - pipeMaterial: pvc | abs | cast-iron + - system: waste (sloped drains) | vent (level / vertical air pipes) + `, +) +export type PipeSegmentNode = z.infer +export type PipeSegmentNodeId = PipeSegmentNode['id'] diff --git a/services/pascal-editor/packages/core/src/schema/nodes/pipe-trap.ts b/services/pascal-editor/packages/core/src/schema/nodes/pipe-trap.ts new file mode 100644 index 000000000..0eba603fd --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/pipe-trap.ts @@ -0,0 +1,41 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * DWV trap — the P-trap between a fixture and the waste system. Holds a + * water seal that blocks sewer gas; every drained fixture has exactly + * one. Modeled as an explicit fitting (not folded into the fixture) so + * the trap-arm rule (IPC 909.1 max developed length to the vent) has a + * node to attach to and the inspector can edit size + arm length. + * + * Local-frame convention (before `rotation`): inlet faces +Y (up, to + * the fixture tailpiece), outlet faces +X (the horizontal trap arm + * toward the vented waste line). + */ +export const PipeTrapNode = BaseNode.extend({ + id: objectId('pipe-trap'), + type: nodeType('pipe-trap'), + // Level-local meters. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Yaw in radians (the arm direction in plan). + rotation: z.number().default(0), + // Trap size in inches — matches the fixture drain it serves. + diameter: z.number().min(1.25).max(4).default(2), + pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), + // Developed length of the trap arm (trap weir → vent) in meters. The + // draw tool measures it when the arm is drawn; editable in the + // inspector. Drives the IPC 909.1 max-trap-arm check. + armLengthM: z.number().min(0).default(0), +}).describe( + dedent` + DWV trap (P-trap) - the water-seal fitting between a fixture and the waste line. + - position: [x, y, z] level-local meters + - rotation: yaw radians (trap-arm direction in plan) + - diameter: trap size in inches (matches the fixture drain) + - pipeMaterial: pvc | abs | cast-iron + - armLengthM: developed length from trap to vent in meters (IPC 909.1 limited by size) + `, +) +export type PipeTrapNode = z.infer +export type PipeTrapNodeId = PipeTrapNode['id'] diff --git a/services/pascal-editor/packages/core/src/schema/nodes/ridge-vent-defaults.test.ts b/services/pascal-editor/packages/core/src/schema/nodes/ridge-vent-defaults.test.ts new file mode 100644 index 000000000..024992de7 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/ridge-vent-defaults.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test } from 'bun:test' +import { + createDefaultRidgeVentsForSegment, + getRidgeVentLinesForSegment, + isAutoRidgeVentEnabled, + isDefaultRidgeVentNode, + RidgeVentNode, +} from './ridge-vent' +import { + getDutchRoofMetrics, + getRoofSegmentVisibleTopBounds, + ROOF_SHAPE_DEFAULTS, + RoofSegmentNode, +} from './roof-segment' + +describe('createDefaultRidgeVentsForSegment', () => { + test('creates one shingled default ridge vent for gable roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(1) + expect(vents[0]?.name).toBe('Ridge Vent') + expect(vents[0]?.style).toBe('shingled') + expect(vents[0]?.roofSegmentId).toBe(segment.id) + expect(isDefaultRidgeVentNode(vents[0], segment.id)).toBe(true) + }) + + test('keeps generated gable ridge vents anchored to the untrimmed ridge', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { left: 1, right: 2, front: 0, back: 0 }, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(1) + expect(vents[0]?.length).toBeCloseTo(8) + expect(vents[0]?.position[0]).toBeCloseTo(0) + }) + + test('creates top ridge plus four hip vents for rectangular hip roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'hip', + width: 8, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(5) + expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1) + expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4) + for (const vent of vents) { + expect(vent.style).toBe('shingled') + expect(vent.length).toBeGreaterThan(0.4) + expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true) + } + }) + + test('omits the collapsed top ridge on square hip roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'hip', + width: 6, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(4) + expect(vents.every((vent) => vent.name === 'Hip Ridge Vent')).toBe(true) + }) + + test('creates a top ridge plus four hip vents for width-axis Dutch roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1) + expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4) + // Width-axis Dutch ridge runs along X (constant Z = 0). + const metrics = getDutchRoofMetrics(segment) + const ridge = vents.find((vent) => vent.name === 'Ridge Vent') + const frontRightHip = vents.find( + (vent) => + vent.name === 'Hip Ridge Vent' && + (vent.position[0] ?? 0) > 0 && + (vent.position[2] ?? 0) > 0, + ) + const expectedRakeReach = Math.min( + segment.dutchGabletRake, + Math.max(0, segment.width / 2 - metrics.waistHalfX) * 0.98, + ) + expect(ridge?.position[2]).toBeCloseTo(0) + expect(ridge?.length).toBeCloseTo((metrics.waistHalfX + expectedRakeReach) * 2, 2) + expect(frontRightHip?.position[0]).toBeCloseTo((4 + 2.93) / 2, 2) + expect(frontRightHip?.position[2]).toBeCloseTo((3 + 1.5) / 2, 2) + for (const vent of vents) { + expect(vent.style).toBe('shingled') + expect(vent.length).toBeGreaterThan(0.4) + expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true) + } + }) + + test('treats legacy segments with generated ridge vents as auto-enabled', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + const nodes = Object.fromEntries(vents.map((vent) => [vent.id, vent])) + + expect( + isAutoRidgeVentEnabled( + { + id: segment.id, + children: vents.map((vent) => vent.id), + metadata: {}, + }, + nodes, + ), + ).toBe(true) + }) + + test('treats legacy preset-white ridge vents as generated defaults', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + roofType: 'gable', + width: 8, + depth: 6, + }) + const legacyVent = RidgeVentNode.parse({ + id: 'rvent_legacy' as never, + roofSegmentId: segment.id, + name: 'Ridge Vent', + style: 'shingled', + materialPreset: 'preset-white', + position: [0, 0, 0], + length: 8, + }) + + expect(isDefaultRidgeVentNode(legacyVent, segment.id)).toBe(true) + }) + + test('creates a Z-oriented ridge plus four hip lines for depth-axis Dutch roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 6, + depth: 8, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const lines = getRidgeVentLinesForSegment(segment) + const metrics = getDutchRoofMetrics(segment) + const expectedRakeReach = Math.min( + segment.dutchGabletRake, + Math.max(0, segment.depth / 2 - metrics.waistHalfZ) * 0.98, + ) + + const ridges = lines.filter((line) => line.name === 'Ridge Vent') + const hips = lines.filter((line) => line.name === 'Hip Ridge Vent') + expect(ridges).toHaveLength(1) + expect(hips).toHaveLength(4) + // Depth-axis Dutch ridge runs along Z (constant X = 0). + expect(ridges[0]?.start[0]).toBeCloseTo(0) + expect(ridges[0]?.end[0]).toBeCloseTo(0) + expect(Math.abs(ridges[0]?.start[1] ?? 0)).toBeCloseTo( + metrics.waistHalfZ + expectedRakeReach, + 2, + ) + }) + + test('keeps Dutch hip lines on the rendered arris when the roof has overhang', () => { + // Overhang + shingle thickness expand the rendered roof; the eave corners + // and the waist must share that expanded frame, otherwise the hip lines + // tilt off the arris and the vents sink into the slope. + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + overhang: 0.5, + wallThickness: 0.2, + shingleThickness: 0.05, + }) + + const lines = getRidgeVentLinesForSegment(segment) + const hips = lines.filter((line) => line.name === 'Hip Ridge Vent') + const ridge = lines.find((line) => line.name === 'Ridge Vent') + expect(hips).toHaveLength(4) + expect(ridge).toBeDefined() + + const bounds = getRoofSegmentVisibleTopBounds(segment) + const { axis, inset } = getDutchRoofMetrics(segment) + const waistLengthRatio = segment.dutchWaistLengthRatio + // width 8 >= depth 6 -> axis 'x': the ridge runs along X (waist scaled by + // waistLengthRatio), and Z is the clean hipped axis (inset exactly). + expect(axis).toBe('x') + const halfWExpanded = bounds.maxX + const halfDExpanded = bounds.maxZ + const expectedWaistX = (halfWExpanded - inset) * waistLengthRatio + const expectedWaistZ = halfDExpanded - inset + const expectedRakeReach = Math.min( + segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, + Math.max(0, halfWExpanded - expectedWaistX) * 0.98, + ) + + for (const hip of hips) { + const [ex, ez] = hip.start + const [wx, wz] = hip.end + // Eave end on an expanded-bounds corner; upper end at the rendered rake + // termination where the lower slope starts, derived from the SAME + // expanded frame (not the base-dim inner waist). + expect(Math.abs(ex)).toBeCloseTo(halfWExpanded) + expect(Math.abs(ez)).toBeCloseTo(halfDExpanded) + expect(Math.abs(wx)).toBeCloseTo(expectedWaistX + expectedRakeReach) + expect(Math.abs(wz)).toBeCloseTo(expectedWaistZ) + } + }) + + test('creates top ridge plus four upper hip vents plus four lower-slope vents for mansard roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'mansard', + width: 8, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(9) + expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1) + expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4) + expect(vents.filter((vent) => vent.name === 'Slope Ridge Vent')).toHaveLength(4) + expect(vents.find((vent) => vent.name === 'Ridge Vent')?.length).toBeLessThan(segment.width) + const slopeVents = vents.filter((vent) => vent.name === 'Slope Ridge Vent') + const slopeRotations = slopeVents.map((vent) => Math.abs(vent.rotation)) + expect(slopeRotations.every((rotation) => rotation > 0.1)).toBe(true) + expect(slopeRotations.every((rotation) => Math.abs(rotation - Math.PI / 2) > 0.1)).toBe(true) + expect( + slopeVents.every( + (vent) => + Math.abs(vent.position[0]) > segment.width / 2 - 0.8 && + Math.abs(vent.position[2]) > segment.depth / 2 - 0.8, + ), + ).toBe(true) + expect( + new Set( + slopeVents.map((vent) => `${Math.sign(vent.position[0])},${Math.sign(vent.position[2])}`), + ).size, + ).toBe(4) + for (const vent of vents) { + expect(vent.style).toBe('shingled') + expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true) + } + }) +}) diff --git a/services/pascal-editor/packages/core/src/schema/nodes/ridge-vent.ts b/services/pascal-editor/packages/core/src/schema/nodes/ridge-vent.ts new file mode 100644 index 000000000..31433d88b --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/ridge-vent.ts @@ -0,0 +1,307 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' +import { + getDutchRoofMetrics, + getRoofSegmentVisibleTopBounds, + ROOF_SHAPE_DEFAULTS, + type RoofSegmentNode, + type RoofSegmentTrim, +} from './roof-segment' + +const MIN_DEFAULT_RIDGE_VENT_LENGTH_M = 0.4 +const DEFAULT_RIDGE_VENT_GENERATOR = 'default-ridge-vent' +const AUTO_RIDGE_VENT_METADATA_KEY = 'autoRidgeVent' +const LEGACY_DEFAULT_RIDGE_VENT_PRESET = 'preset-white' +const UNTRIMMED_RIDGE_VENT_BOUNDS_TRIM: RoofSegmentTrim = { + left: 0, + right: 0, + front: 0, + back: 0, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, +} + +export type RidgeVentLine = { + name: string + start: [number, number] + end: [number, number] +} + +export const RidgeVentNode = BaseNode.extend({ + id: objectId('rvent'), + type: nodeType('ridge-vent'), + + material: MaterialSchema.optional(), + // Unpainted ridge vents inherit the roof top material in the renderer. + materialPreset: z.string().optional(), + + roofSegmentId: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + length: z.number().default(2.0), + width: z.number().default(0.3), + // Taller default than the old paper-thin shell so the solid body reads + // with real presence on the ridge; still adjustable down to 0.03. + height: z.number().default(0.1), + + style: z.enum(['standard', 'shingled', 'metal']).default('standard'), + endCaps: z.boolean().default(true), +}).describe( + dedent` + Ridge vent — a ventilation strip that sits along the ridge (peak) of a + roof segment. Parented to a roof-segment; position is segment-local. + - length: extent along the ridge + - width: vent width straddling the ridge center + - height: profile height above the ridge surface + - style: standard (curved cap) / shingled / metal + - endCaps: cap both ends or leave open + `, +) + +export type RidgeVentNode = z.infer + +export function getRidgeVentLinesForSegment(segment: RoofSegmentNode): RidgeVentLine[] { + const bounds = getRoofSegmentVisibleTopBounds({ + ...segment, + trim: UNTRIMMED_RIDGE_VENT_BOUNDS_TRIM, + }) + const { width, depth, minX, maxX, minZ, maxZ } = bounds + if (segment.roofType === 'flat' || segment.roofType === 'shed') return [] + + const halfW = width / 2 + const halfD = depth / 2 + const ridgeZVisible = minZ <= 0 && maxZ >= 0 + const ridgeXVisible = minX <= 0 && maxX >= 0 + + if (segment.roofType === 'mansard') { + const inset = Math.min( + Math.min(width, depth) * + (segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio), + Math.max(0, Math.min(width, depth) / 2 - 0.01), + ) + const shoulderMinX = minX + inset + const shoulderMaxX = maxX - inset + const shoulderMinZ = minZ + inset + const shoulderMaxZ = maxZ - inset + const topW = Math.max(0, shoulderMaxX - shoulderMinX) + const topD = Math.max(0, shoulderMaxZ - shoulderMinZ) + const lowerSlopeLines: RidgeVentLine[] = [ + { + name: 'Slope Ridge Vent', + start: [shoulderMinX, shoulderMaxZ], + end: [minX, maxZ], + }, + { + name: 'Slope Ridge Vent', + start: [shoulderMaxX, shoulderMaxZ], + end: [maxX, maxZ], + }, + { + name: 'Slope Ridge Vent', + start: [shoulderMaxX, shoulderMinZ], + end: [maxX, minZ], + }, + { + name: 'Slope Ridge Vent', + start: [shoulderMinX, shoulderMinZ], + end: [minX, minZ], + }, + ] + + if (topW >= topD) { + const leftRidge: [number, number] = [shoulderMinX + topD / 2, 0] + const rightRidge: [number, number] = [shoulderMaxX - topD / 2, 0] + return [ + ...(ridgeZVisible ? [{ name: 'Ridge Vent', start: leftRidge, end: rightRidge }] : []), + { name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMaxZ], end: leftRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMinZ], end: leftRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMaxZ], end: rightRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMinZ], end: rightRidge }, + ...lowerSlopeLines, + ] + } + + const frontRidge: [number, number] = [0, shoulderMaxZ - topW / 2] + const backRidge: [number, number] = [0, shoulderMinZ + topW / 2] + return [ + ...(ridgeXVisible ? [{ name: 'Ridge Vent', start: frontRidge, end: backRidge }] : []), + { name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMaxZ], end: frontRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMaxZ], end: frontRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMinZ], end: backRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMinZ], end: backRidge }, + ...lowerSlopeLines, + ] + } + + if (segment.roofType === 'dutch') { + const { axis, inset } = getDutchRoofMetrics(segment) + // The rendered hip arris and waist come from the EXPANDED (overhang + + // shingle) rectangle — the same dims the eave corners below use — while + // the inset is base-derived (matches getDutchRoofMetrics / the brush + // builder). Deriving the waist from `getDutchRoofMetrics`' own base-dim + // half-spans would tilt the hip lines off the arris on any roof with + // overhang, sinking the vents into the slope. Mirror the mansard branch: + // one coordinate system (expanded bounds) for both eave and waist. + const waistLengthRatio = + segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio + const waistHalfX = + axis === 'x' ? Math.max(0, (halfW - inset) * waistLengthRatio) : Math.max(0, halfW - inset) + const waistHalfZ = + axis === 'x' ? Math.max(0, halfD - inset) : Math.max(0, (halfD - inset) * waistLengthRatio) + if (!(waistHalfX > 0.001 && waistHalfZ > 0.001)) return [] + + // Dutch lower hip vents should terminate where the lower slope actually + // meets the gablet rake, not at the inner waist line of the upper gable + // triangle. Mirror the rendered roof shell's "outer waist" cap with the + // same expanded-bounds frame we use for the eave corners above. + const rake = segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake + const rakeReach = + axis === 'x' + ? Math.min(Math.max(0, rake), Math.max(0, halfW - waistHalfX) * 0.98) + : Math.min(Math.max(0, rake), Math.max(0, halfD - waistHalfZ) * 0.98) + const rakeEndHalfX = axis === 'x' ? waistHalfX + rakeReach : waistHalfX + const rakeEndHalfZ = axis === 'x' ? waistHalfZ : waistHalfZ + rakeReach + + const mainRidgeVisible = axis === 'x' ? ridgeZVisible : ridgeXVisible + const ridgeStart: [number, number] = axis === 'x' ? [-rakeEndHalfX, 0] : [0, rakeEndHalfZ] + const ridgeEnd: [number, number] = axis === 'x' ? [rakeEndHalfX, 0] : [0, -rakeEndHalfZ] + return [ + ...(mainRidgeVisible ? [{ name: 'Ridge Vent', start: ridgeStart, end: ridgeEnd }] : []), + { name: 'Hip Ridge Vent', start: [minX, maxZ], end: [-rakeEndHalfX, rakeEndHalfZ] }, + { name: 'Hip Ridge Vent', start: [maxX, maxZ], end: [rakeEndHalfX, rakeEndHalfZ] }, + { name: 'Hip Ridge Vent', start: [maxX, minZ], end: [rakeEndHalfX, -rakeEndHalfZ] }, + { name: 'Hip Ridge Vent', start: [minX, minZ], end: [-rakeEndHalfX, -rakeEndHalfZ] }, + ] + } + + if (segment.roofType !== 'hip') { + if (!ridgeZVisible) return [] + return [ + { + name: 'Ridge Vent', + start: [minX, 0], + end: [maxX, 0], + }, + ] + } + + if (width >= depth) { + const leftRidge: [number, number] = [minX + halfD, 0] + const rightRidge: [number, number] = [maxX - halfD, 0] + return [ + ...(ridgeZVisible ? [{ name: 'Ridge Vent', start: leftRidge, end: rightRidge }] : []), + { name: 'Hip Ridge Vent', start: [minX, maxZ], end: leftRidge }, + { name: 'Hip Ridge Vent', start: [minX, minZ], end: leftRidge }, + { name: 'Hip Ridge Vent', start: [maxX, maxZ], end: rightRidge }, + { name: 'Hip Ridge Vent', start: [maxX, minZ], end: rightRidge }, + ] + } + + const frontRidge: [number, number] = [0, maxZ - halfW] + const backRidge: [number, number] = [0, minZ + halfW] + return [ + ...(ridgeXVisible ? [{ name: 'Ridge Vent', start: frontRidge, end: backRidge }] : []), + { name: 'Hip Ridge Vent', start: [minX, maxZ], end: frontRidge }, + { name: 'Hip Ridge Vent', start: [maxX, maxZ], end: frontRidge }, + { name: 'Hip Ridge Vent', start: [minX, minZ], end: backRidge }, + { name: 'Hip Ridge Vent', start: [maxX, minZ], end: backRidge }, + ] +} + +function getLineYaw(start: [number, number], end: [number, number]): number { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + return Math.atan2(-dz, dx) +} + +export function createDefaultRidgeVentsForSegment(segment: RoofSegmentNode): RidgeVentNode[] { + return getRidgeVentLinesForSegment(segment) + .map((line) => { + const length = Math.hypot(line.end[0] - line.start[0], line.end[1] - line.start[1]) + if (length < MIN_DEFAULT_RIDGE_VENT_LENGTH_M) return null + + return RidgeVentNode.parse({ + name: line.name, + roofSegmentId: segment.id, + position: [(line.start[0] + line.end[0]) / 2, 0, (line.start[1] + line.end[1]) / 2], + rotation: getLineYaw(line.start, line.end), + length, + style: 'shingled', + metadata: { generatedBy: DEFAULT_RIDGE_VENT_GENERATOR }, + }) + }) + .filter((vent): vent is RidgeVentNode => vent !== null) +} + +export function isDefaultRidgeVentNode( + node: unknown, + roofSegmentId?: RoofSegmentNode['id'], +): node is RidgeVentNode { + const parsed = RidgeVentNode.safeParse(node) + if (!parsed.success) return false + const vent = parsed.data + if (roofSegmentId && vent.roofSegmentId !== roofSegmentId) return false + const metadata = vent.metadata + if ( + typeof metadata === 'object' && + metadata !== null && + (metadata as Record).generatedBy === DEFAULT_RIDGE_VENT_GENERATOR + ) { + return true + } + + const hasDefaultName = + vent.name === 'Ridge Vent' || + vent.name === 'Hip Ridge Vent' || + vent.name === 'Shoulder Ridge Vent' || + vent.name === 'Slope Ridge Vent' + return ( + hasDefaultName && + vent.style === 'shingled' && + vent.material === undefined && + vent.materialPreset === LEGACY_DEFAULT_RIDGE_VENT_PRESET + ) +} + +function metadataRecord(metadata: unknown): Record { + if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) { + return metadata as Record + } + return {} +} + +export function hasAutoRidgeVentMetadata( + segment: Pick, +): segment is Pick & { + metadata: Record & { autoRidgeVent: boolean } +} { + return typeof metadataRecord(segment.metadata)[AUTO_RIDGE_VENT_METADATA_KEY] === 'boolean' +} + +export function isAutoRidgeVentEnabled( + segment: Pick, + nodes?: Record, +): boolean { + const metadataValue = metadataRecord(segment.metadata)[AUTO_RIDGE_VENT_METADATA_KEY] + if (typeof metadataValue === 'boolean') { + return metadataValue + } + + if (!nodes) return false + return (segment.children ?? []).some((childId) => + isDefaultRidgeVentNode(nodes[childId], segment.id), + ) +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-shape.test.ts b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-shape.test.ts new file mode 100644 index 000000000..64ff58a17 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-shape.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from 'bun:test' +import { + getDutchEndSlopeFaces, + getDutchRoofShapeMetrics, + getRoofModuleFaces, + getRoofShapeRatios, +} from './roof-segment-shape' + +describe('roof segment shape', () => { + test('dutch shell is built as one complete non-duplicated face set', () => { + const wh = 3 + const rh = 2 + const faces = getRoofModuleFaces({ + type: 'dutch', + w: 8, + d: 6, + wh, + rh, + baseY: 0, + insets: { dutchI: 1.5 }, + baseW: 8, + baseD: 6, + tanTheta: 1, + shapeRatios: getRoofShapeRatios({ + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + dutchGabletRake: 0.25, + }), + }) + const peakY = wh + rh + const peakFaces = faces.filter((face) => + face.some((vertex) => Math.abs(vertex.y - peakY) < 1e-6), + ) + const signatures = new Set( + faces.map((face) => + face + .map((vertex) => `${vertex.x.toFixed(3)},${vertex.y.toFixed(3)},${vertex.z.toFixed(3)}`) + .sort() + .join('|'), + ), + ) + + expect(faces).toHaveLength(13) + expect(peakFaces).toHaveLength(4) + expect(signatures.size).toBe(faces.length) + + const lowerRightHip = faces.find((face) => + face.some( + (vertex) => + Math.abs(vertex.x - 2.75) < 1e-6 && + Math.abs(vertex.y - 4) < 1e-6 && + Math.abs(vertex.z - 1.5) < 1e-6, + ), + ) + const gableTriangle = faces.find( + (face) => + face.length === 3 && + face.some((vertex) => vertex.x === 2.5 && vertex.y === 5) && + face.some((vertex) => vertex.x === 2.5 && vertex.z === 1.5), + ) + + expect(lowerRightHip).toBeDefined() + expect(gableTriangle?.some((vertex) => vertex.x === 2.75)).toBe(false) + }) + + test('dutch depth-axis shell rotates the upper gable correctly', () => { + const faces = getRoofModuleFaces({ + type: 'dutch', + w: 6, + d: 8, + wh: 3, + rh: 2, + baseY: 0, + insets: { dutchI: 1.5 }, + baseW: 6, + baseD: 8, + tanTheta: 1, + shapeRatios: getRoofShapeRatios({ + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + dutchGabletRake: 0.25, + }), + }) + + const peakFaces = faces.filter((face) => face.some((vertex) => vertex.y === 5)) + const ridgeOnlyOnZAxis = peakFaces + .flat() + .filter((vertex) => vertex.y === 5) + .every((vertex) => vertex.x === 0) + + expect(peakFaces).toHaveLength(4) + expect(ridgeOnlyOnZAxis).toBe(true) + }) + + test('dutch end hip slope extends inward until it meets the gable triangle', () => { + const ratios = getRoofShapeRatios({ + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + dutchGabletRake: 0.75, + }) + const metrics = getDutchRoofShapeMetrics({ + w: 8, + d: 6, + wh: 3, + rh: 2, + dutchI: 1.5, + baseW: 8, + baseD: 6, + shapeRatios: ratios, + }) + const faces = getRoofModuleFaces({ + type: 'dutch', + w: 8, + d: 6, + wh: 3, + rh: 2, + baseY: 0, + insets: { dutchI: 1.5 }, + baseW: 8, + baseD: 6, + tanTheta: 1, + shapeRatios: ratios, + dutchTopRakeThickness: 0.21, + }) + + expect(metrics?.innerWaistHalfX).toBe(2.5) + expect(metrics?.outerWaistHalfX).toBe(3.25) + + // The end slope now clips against the rake's lower inner edge and is + // reprojected back onto the end-slope plane, so the shorter top edge stays + // planar instead of twisting. + const hipTopFace = faces.find( + (face) => + face.some( + (vertex) => + Math.abs(vertex.x - 4) < 1e-6 && + Math.abs(vertex.y - 3) < 1e-6 && + Math.abs(vertex.z - 3) < 1e-6, + ) && + face.some( + (vertex) => + Math.abs(vertex.x - 3.0325) < 1e-6 && + Math.abs(vertex.y - 4.29) < 1e-6 && + Math.abs(vertex.z - 0.75) < 1e-6, + ) && + face.some( + (vertex) => + Math.abs(vertex.x - 3.0325) < 1e-6 && + Math.abs(vertex.y - 4.29) < 1e-6 && + Math.abs(vertex.z + 0.75) < 1e-6, + ), + ) + const gableTriangle = faces.find( + (face) => + face.length === 3 && + face.some((vertex) => vertex.x === 2.5 && vertex.y === 5) && + face.some((vertex) => vertex.x === 2.5 && vertex.z === 1.5), + ) + + expect(hipTopFace).toBeDefined() + expect(gableTriangle?.some((vertex) => vertex.x === 3.25)).toBe(false) + }) + + test('excludeDutchEndSlopes pulls the end slopes out into getDutchEndSlopeFaces', () => { + const ratios = getRoofShapeRatios({ + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + dutchGabletRake: 0.75, + }) + const args = { + type: 'dutch' as const, + w: 8, + d: 6, + wh: 3, + rh: 2, + baseY: 0, + insets: { dutchI: 1.5 }, + baseW: 8, + baseD: 6, + tanTheta: 1, + shapeRatios: ratios, + dutchTopRakeThickness: 0.21, + } + + const full = getRoofModuleFaces(args) + const shell = getRoofModuleFaces({ ...args, excludeDutchEndSlopes: true }) + const endSlopes = getDutchEndSlopeFaces({ + w: 8, + d: 6, + wh: 3, + rh: 2, + insets: { dutchI: 1.5 }, + baseW: 8, + baseD: 6, + shapeRatios: ratios, + dutchTopRakeThickness: 0.21, + }) + + // The two end slopes leave the shell and reappear in the standalone set. + expect(shell).toHaveLength(full.length - 2) + expect(endSlopes).toHaveLength(2) + + // The standalone end slopes are 6-point polygons whose shorter top edge is + // clipped to the rake's lower inner edge and then kept on the end-slope + // plane. + const endIsEndSlope = endSlopes.every( + (face) => + face.length === 6 && + face.some((vertex) => Math.abs(Math.abs(vertex.x) - 3.25) < 1e-6) && + face.some((vertex) => Math.abs(Math.abs(vertex.x) - 3.0325) < 1e-6) && + face.some((vertex) => Math.abs(Math.abs(vertex.x) - 4) < 1e-6), + ) + expect(endIsEndSlope).toBe(true) + + // The removed faces are exactly the end slopes — the shell keeps every + // other face the full module produced. + const signature = (face: { x: number; y: number; z: number }[]) => + face + .map((vertex) => `${vertex.x.toFixed(3)},${vertex.y.toFixed(3)},${vertex.z.toFixed(3)}`) + .join('|') + const shellSigs = new Set(shell.map(signature)) + const removed = full.filter((face) => !shellSigs.has(signature(face))) + expect(removed).toHaveLength(2) + }) +}) diff --git a/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-shape.ts b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-shape.ts new file mode 100644 index 000000000..ac1d5787d --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-shape.ts @@ -0,0 +1,441 @@ +import { ROOF_SHAPE_DEFAULTS, type RoofType } from './roof-segment' + +export type RoofShapeFaceVertex = { + x: number + y: number + z: number +} + +export type RoofShapeInsets = { + iF?: number + iB?: number + iL?: number + iR?: number + dutchI?: number +} + +export type RoofShapeRatios = { + gambrelLowerWidthRatio: number + mansardSteepWidthRatio: number + dutchHipWidthRatio: number + dutchHipHeightRatio: number + dutchWaistLengthRatio: number + dutchGabletRake: number +} + +export function getRoofShapeRatios(input: { + gambrelLowerWidthRatio?: number + mansardSteepWidthRatio?: number + dutchHipWidthRatio?: number + dutchHipHeightRatio?: number + dutchWaistLengthRatio?: number + dutchGabletRake?: number +}): RoofShapeRatios { + return { + gambrelLowerWidthRatio: + input.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, + mansardSteepWidthRatio: + input.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio, + dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + dutchWaistLengthRatio: input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + dutchGabletRake: input.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, + } +} + +export type DutchRoofShapeMetrics = { + axis: 'width' | 'depth' + inset: number + middleHeight: number + peakHeight: number + rakeReach: number + innerWaistHalfX: number + innerWaistHalfZ: number + outerWaistHalfX: number + outerWaistHalfZ: number +} + +export function getDutchRoofShapeMetrics(input: { + w: number + d: number + wh: number + rh: number + dutchI?: number + baseW: number + baseD: number + shapeRatios: RoofShapeRatios +}): DutchRoofShapeMetrics | null { + const fallbackInset = Math.min(input.baseW, input.baseD) * input.shapeRatios.dutchHipWidthRatio + const maxI = Math.max(0, Math.min(input.w, input.d) / 2 - 0.005) + const inset = Math.min(Math.max(0, input.dutchI ?? fallbackInset), maxI) + const peakHeight = input.wh + Math.max(0.001, input.rh) + const middleHeight = input.wh + input.rh * input.shapeRatios.dutchHipHeightRatio + if (!(inset > 0.001) || !(peakHeight > middleHeight + 0.001)) return null + + if (input.w >= input.d) { + const innerWaistHalfX = Math.max( + 0, + (input.w / 2 - inset) * input.shapeRatios.dutchWaistLengthRatio, + ) + const innerWaistHalfZ = Math.max(0, input.d / 2 - inset) + if (!(innerWaistHalfX > 0.001 && innerWaistHalfZ > 0.001)) return null + + const rakeReach = Math.min( + Math.max(0, input.shapeRatios.dutchGabletRake), + Math.max(0, input.w / 2 - innerWaistHalfX) * 0.98, + ) + + return { + axis: 'width', + inset, + middleHeight, + peakHeight, + rakeReach, + innerWaistHalfX, + innerWaistHalfZ, + outerWaistHalfX: innerWaistHalfX + rakeReach, + outerWaistHalfZ: innerWaistHalfZ, + } + } + + const innerWaistHalfX = Math.max(0, input.w / 2 - inset) + const innerWaistHalfZ = Math.max( + 0, + (input.d / 2 - inset) * input.shapeRatios.dutchWaistLengthRatio, + ) + if (!(innerWaistHalfX > 0.001 && innerWaistHalfZ > 0.001)) return null + + const rakeReach = Math.min( + Math.max(0, input.shapeRatios.dutchGabletRake), + Math.max(0, input.d / 2 - innerWaistHalfZ) * 0.98, + ) + + return { + axis: 'depth', + inset, + middleHeight, + peakHeight, + rakeReach, + innerWaistHalfX, + innerWaistHalfZ, + outerWaistHalfX: innerWaistHalfX, + outerWaistHalfZ: innerWaistHalfZ + rakeReach, + } +} + +export function getRoofShapeInsets(input: { + roofType: RoofType + width: number + depth: number + wh: number + baseY: number + isVoid: boolean + brushW: number + brushD: number + tanTheta: number + shingleThickness: number + dutchHipWidthRatio: number +}): RoofShapeInsets { + let inset = (input.wh - input.baseY) * input.tanTheta + const maxSafeInset = Math.min(input.brushW, input.brushD) / 2 - 0.005 + if (inset > maxSafeInset) inset = maxSafeInset + + let iF = 0 + let iB = 0 + let iL = 0 + let iR = 0 + if (input.roofType === 'hip' || input.roofType === 'mansard' || input.roofType === 'dutch') { + iF = inset + iB = inset + iL = inset + iR = inset + } else if (input.roofType === 'gable' || input.roofType === 'gambrel') { + iF = inset + iB = inset + } else if (input.roofType === 'shed') { + iF = inset + } + + let dutchI = Math.min(input.width, input.depth) * input.dutchHipWidthRatio + if (input.isVoid) dutchI += input.shingleThickness + return { iF, iB, iL, iR, dutchI } +} + +export function getDutchEndSlopeFaces(input: { + w: number + d: number + wh: number + rh: number + insets: RoofShapeInsets + baseW: number + baseD: number + shapeRatios: RoofShapeRatios + dutchTopRakeThickness?: number +}): RoofShapeFaceVertex[][] { + const dutch = getDutchRoofShapeMetrics({ + w: input.w, + d: input.d, + wh: input.wh, + rh: input.rh, + dutchI: input.insets.dutchI, + baseW: input.baseW, + baseD: input.baseD, + shapeRatios: input.shapeRatios, + }) + if (!dutch) return [] + + const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z }) + const e1 = v(-input.w / 2, input.wh, input.d / 2) + const e2 = v(input.w / 2, input.wh, input.d / 2) + const e3 = v(input.w / 2, input.wh, -input.d / 2) + const e4 = v(-input.w / 2, input.wh, -input.d / 2) + + // The hip end slope is constructed only up to the outer waist (rake) line. + // Extend its top edge inward along the same hip rulings until it reaches the + // gablet's inner triangle (the inner waist line), continuing each + // eave→outer-waist ridge line so the face stays planar and the pitch is + // unchanged — the edge climbs past middleHeight and meets the gablet face. + const lerp = (a: RoofShapeFaceVertex, b: RoofShapeFaceVertex, t: number): RoofShapeFaceVertex => + v(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t) + const lowerAlongY = (point: RoofShapeFaceVertex): RoofShapeFaceVertex => + v(point.x, point.y - Math.max(0, input.dutchTopRakeThickness ?? 0), point.z) + + if (dutch.axis === 'width') { + const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const r1 = v(-dutch.innerWaistHalfX, input.wh + input.rh, 0) + const r2 = v(dutch.innerWaistHalfX, input.wh + input.rh, 0) + const projectWidthAxisPointToEndSlope = ( + point: RoofShapeFaceVertex, + side: 1 | -1, + ): RoofShapeFaceVertex => { + const denomY = dutch.middleHeight - input.wh + const tPlane = Math.abs(denomY) > 1e-9 ? (point.y - input.wh) / denomY : 0 + const x = side * (input.w / 2 + (dutch.outerWaistHalfX - input.w / 2) * tPlane) + return v(x, point.y, point.z) + } + const top2 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m2, r2, 0.5)), 1) + const top3 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m3, r2, 0.5)), 1) + const top1 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m1, r1, 0.5)), -1) + const top4 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m4, r1, 0.5)), -1) + return [ + [e2, e3, o3, top3, top2, o2], + [e4, e1, o1, top1, top4, o4], + ] + } + + const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const r1 = v(0, input.wh + input.rh, dutch.innerWaistHalfZ) + const r2 = v(0, input.wh + input.rh, -dutch.innerWaistHalfZ) + const projectDepthAxisPointToEndSlope = ( + point: RoofShapeFaceVertex, + side: 1 | -1, + ): RoofShapeFaceVertex => { + const denomY = dutch.middleHeight - input.wh + const tPlane = Math.abs(denomY) > 1e-9 ? (point.y - input.wh) / denomY : 0 + const z = side * (input.d / 2 + (dutch.outerWaistHalfZ - input.d / 2) * tPlane) + return v(point.x, point.y, z) + } + const top2 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m2, r1, 0.5)), 1) + const top1 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m1, r1, 0.5)), 1) + const top4 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m4, r2, 0.5)), -1) + const top3 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m3, r2, 0.5)), -1) + return [ + [e1, e2, o2, top2, top1, o1], + [e3, e4, o4, top4, top3, o3], + ] +} + +export function getRoofModuleFaces(input: { + type: RoofType + w: number + d: number + wh: number + rh: number + baseY: number + insets: RoofShapeInsets + baseW: number + baseD: number + tanTheta: number + shapeRatios: RoofShapeRatios + excludeDutchEndSlopes?: boolean + dutchTopRakeThickness?: number +}): RoofShapeFaceVertex[][] { + const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z }) + const { iF = 0, iB = 0, iL = 0, iR = 0 } = input.insets + + const b1 = v(-input.w / 2 + iL, input.baseY, input.d / 2 - iF) + const b2 = v(input.w / 2 - iR, input.baseY, input.d / 2 - iF) + const b3 = v(input.w / 2 - iR, input.baseY, -input.d / 2 + iB) + const b4 = v(-input.w / 2 + iL, input.baseY, -input.d / 2 + iB) + const bottom = [b4, b3, b2, b1] + + const e1 = v(-input.w / 2, input.wh, input.d / 2) + const e2 = v(input.w / 2, input.wh, input.d / 2) + const e3 = v(input.w / 2, input.wh, -input.d / 2) + const e4 = v(-input.w / 2, input.wh, -input.d / 2) + + const faces: RoofShapeFaceVertex[][] = [] + faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom) + + const h = input.wh + Math.max(0.001, input.rh) + + if (input.type === 'flat' || input.rh === 0) { + faces.push([e1, e2, e3, e4]) + } else if (input.type === 'gable') { + const r1 = v(-input.w / 2, h, 0) + const r2 = v(input.w / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else if (input.type === 'hip') { + if (Math.abs(input.w - input.d) < 0.01) { + const r = v(0, h, 0) + faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r]) + } else if (input.w >= input.d) { + const r1 = v(-input.w / 2 + input.d / 2, h, 0) + const r2 = v(input.w / 2 - input.d / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else { + const r1 = v(0, h, input.d / 2 - input.w / 2) + const r2 = v(0, h, -input.d / 2 + input.w / 2) + faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) + } + } else if (input.type === 'shed') { + const t1 = v(-input.w / 2, h, -input.d / 2) + const t2 = v(input.w / 2, h, -input.d / 2) + faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1]) + } else if (input.type === 'gambrel') { + const mz = (input.baseD / 2) * input.shapeRatios.gambrelLowerWidthRatio + const dist = input.d / 2 - mz + const mh = input.wh + dist * (input.tanTheta || 0) + + const m1 = v(-input.w / 2, mh, mz) + const m2 = v(input.w / 2, mh, mz) + const m3 = v(input.w / 2, mh, -mz) + const m4 = v(-input.w / 2, mh, -mz) + const r1 = v(-input.w / 2, h, 0) + const r2 = v(input.w / 2, h, 0) + faces.push( + [e4, e1, m1, r1, m4], + [e2, e3, m3, r2, m2], + [e1, e2, m2, m1], + [m1, m2, r2, r1], + [e3, e4, m4, m3], + [m3, m4, r1, r2], + ) + } else if (input.type === 'mansard') { + const i = Math.min(input.baseW, input.baseD) * input.shapeRatios.mansardSteepWidthRatio + const mh = input.wh + i * (input.tanTheta || 0) + + const m1 = v(-input.w / 2 + i, mh, input.d / 2 - i) + const m2 = v(input.w / 2 - i, mh, input.d / 2 - i) + const m3 = v(input.w / 2 - i, mh, -input.d / 2 + i) + const m4 = v(-input.w / 2 + i, mh, -input.d / 2 + i) + const topW = input.w - i * 2 + const topD = input.d - i * 2 + + faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4]) + + if (Math.abs(topW - topD) < 0.01) { + const r = v(0, h, 0) + faces.push([m4, m1, r], [m1, m2, r], [m2, m3, r], [m3, m4, r]) + } else if (topW >= topD) { + const r1 = v(-topW / 2 + topD / 2, h, 0) + const r2 = v(topW / 2 - topD / 2, h, 0) + faces.push([m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2]) + } else { + const r1 = v(0, h, topD / 2 - topW / 2) + const r2 = v(0, h, -topD / 2 + topW / 2) + faces.push([m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2]) + } + } else if (input.type === 'dutch') { + const dutch = getDutchRoofShapeMetrics({ + w: input.w, + d: input.d, + wh: input.wh, + rh: input.rh, + dutchI: input.insets.dutchI, + baseW: input.baseW, + baseD: input.baseD, + shapeRatios: input.shapeRatios, + }) + if (!dutch) return faces + + const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + + if (dutch.axis === 'width') { + const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const r1 = v(-dutch.innerWaistHalfX, h, 0) + const r2 = v(dutch.innerWaistHalfX, h, 0) + const endSlopes = input.excludeDutchEndSlopes + ? [] + : getDutchEndSlopeFaces({ + w: input.w, + d: input.d, + wh: input.wh, + rh: input.rh, + insets: input.insets, + baseW: input.baseW, + baseD: input.baseD, + shapeRatios: input.shapeRatios, + dutchTopRakeThickness: input.dutchTopRakeThickness, + }) + faces.push([e1, e2, o2, m2, m1, o1], [e3, e4, o4, m4, m3, o3]) + if (endSlopes.length === 2) { + faces.push(...endSlopes) + } else if (!input.excludeDutchEndSlopes) { + faces.push([e2, e3, o3, o2], [e4, e1, o1, o4]) + } + faces.push([m1, m2, r2, r1], [m3, m4, r1, r2]) + faces.push([m4, m1, r1], [m2, m3, r2]) + } else { + const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const r1 = v(0, h, dutch.innerWaistHalfZ) + const r2 = v(0, h, -dutch.innerWaistHalfZ) + const endSlopes = input.excludeDutchEndSlopes + ? [] + : getDutchEndSlopeFaces({ + w: input.w, + d: input.d, + wh: input.wh, + rh: input.rh, + insets: input.insets, + baseW: input.baseW, + baseD: input.baseD, + shapeRatios: input.shapeRatios, + dutchTopRakeThickness: input.dutchTopRakeThickness, + }) + faces.push([e2, e3, o3, m3, m2, o2], [e4, e1, o1, m1, m4, o4]) + if (endSlopes.length === 2) { + faces.push(...endSlopes) + } else if (!input.excludeDutchEndSlopes) { + faces.push([e1, e2, o2, o1], [e3, e4, o4, o3]) + } + faces.push([m2, m3, r2, r1], [m4, m1, r1, r2]) + faces.push([m1, m2, r1], [m3, m4, r2]) + } + } + + return faces +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-surface.test.ts b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-surface.test.ts new file mode 100644 index 000000000..17ba53af0 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-surface.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import { getRoofSegmentSurfaceY, ROOF_SHAPE_DEFAULTS, RoofSegmentNode } from './roof-segment' + +describe('getRoofSegmentSurfaceY', () => { + test('keeps the Dutch width-axis rake on the upper gable slope', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 3, + pitch: 40, + }) + const activeRh = getRoofSegmentSurfaceY(segment, 0, 0) - segment.wallHeight + const upperRise = activeRh * (1 - segment.dutchHipHeightRatio) + const waistHalfZ = + segment.depth / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio + const availableRake = Math.max( + 0, + segment.width / 2 - + (segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) * + segment.dutchWaistLengthRatio, + ) + const rakeReach = Math.min(segment.dutchGabletRake, availableRake * 0.98) + const localX = + (segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) * + segment.dutchWaistLengthRatio + + rakeReach * 0.5 + const localZ = waistHalfZ * 0.5 + + const expected = segment.wallHeight + activeRh - localZ * (upperRise / waistHalfZ) + + expect(localX).toBeGreaterThan( + (segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) * + segment.dutchWaistLengthRatio, + ) + expect(getRoofSegmentSurfaceY(segment, localX, localZ)).toBeCloseTo(expected, 6) + }) + + test('keeps the Dutch depth-axis rake on the upper gable slope', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 6, + depth: 8, + wallHeight: 3, + pitch: 40, + }) + const activeRh = getRoofSegmentSurfaceY(segment, 0, 0) - segment.wallHeight + const upperRise = activeRh * (1 - segment.dutchHipHeightRatio) + const waistHalfX = + segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio + const innerWaistHalfZ = + (segment.depth / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) * + segment.dutchWaistLengthRatio + const rakeReach = Math.min( + segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, + Math.max(0, segment.depth / 2 - innerWaistHalfZ) * 0.98, + ) + const localX = waistHalfX * 0.5 + const localZ = innerWaistHalfZ + rakeReach * 0.5 + + const expected = segment.wallHeight + activeRh - localX * (upperRise / waistHalfX) + + expect(localZ).toBeGreaterThan(innerWaistHalfZ) + expect(getRoofSegmentSurfaceY(segment, localX, localZ)).toBeCloseTo(expected, 6) + }) +}) diff --git a/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-trim.test.ts b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-trim.test.ts new file mode 100644 index 000000000..d35de190c --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-trim.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from 'bun:test' +import { + getRoofSegmentVisibleTopBounds, + normalizeRoofSegmentTrim, + RoofSegmentNode, +} from './roof-segment' + +describe('roof segment trim', () => { + test('defaults legacy segments to no trim', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + }) + + expect(segment.trim).toEqual({ + left: 0, + right: 0, + front: 0, + back: 0, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, + }) + }) + + test('normalizes impossible side totals without inverting the footprint', () => { + const trim = normalizeRoofSegmentTrim({ + width: 4, + depth: 3, + trim: { left: 3, right: 3, front: 2, back: 2 }, + }) + + expect(trim.left + trim.right).toBeCloseTo(3.9) + expect(trim.front + trim.back).toBeCloseTo(2.9) + }) + + test('visible top bounds respect asymmetric trims', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { left: 1, right: 2, front: 0, back: 0 }, + }) + + const bounds = getRoofSegmentVisibleTopBounds(segment) + + expect(bounds.minX).toBeCloseTo(-3) + expect(bounds.maxX).toBeCloseTo(2) + expect(bounds.width).toBeCloseTo(5) + }) + + test('visible top bounds stay finite for legacy partial segments', () => { + const bounds = getRoofSegmentVisibleTopBounds({ + id: 'rseg_legacy', + type: 'roof-segment', + roofType: 'gable', + trim: { left: 1 }, + } as unknown as Parameters[0]) + + expect(Number.isFinite(bounds.minX)).toBe(true) + expect(Number.isFinite(bounds.maxX)).toBe(true) + expect(Number.isFinite(bounds.minZ)).toBe(true) + expect(Number.isFinite(bounds.maxZ)).toBe(true) + expect(Number.isFinite(bounds.width)).toBe(true) + expect(Number.isFinite(bounds.depth)).toBe(true) + }) + + test('diagonal trims can shorten the ridge span', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { left: 0, right: 0, front: 0, back: 0, frontLeft: 4, backRight: 4 }, + }) + + const bounds = getRoofSegmentVisibleTopBounds(segment) + + expect(bounds.minX).toBeCloseTo(-3) + expect(bounds.maxX).toBeCloseTo(3) + expect(bounds.width).toBeCloseTo(6) + }) + + test('diagonal trims support independent width and depth endpoints', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { + frontLeftX: 5, + frontLeftZ: 2, + backRightX: 2, + backRightZ: 5, + }, + }) + + const trim = normalizeRoofSegmentTrim(segment) + const bounds = getRoofSegmentVisibleTopBounds(segment) + + expect(trim.frontLeft).toBeCloseTo(2) + expect(trim.frontLeftX).toBeCloseTo(5) + expect(trim.frontLeftZ).toBeCloseTo(2) + expect(bounds.maxZ).toBeCloseTo(2.6) + expect(bounds.maxX).toBeCloseTo(3.2) + }) +}) diff --git a/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-walls.test.ts b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-walls.test.ts new file mode 100644 index 000000000..61c087f55 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-walls.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from './roof-segment' +import { + getRoofSegmentWallFace, + getRoofSegmentWallFaces, + getRoofWallFaceFrame, + roofFacePointToSegment, + segmentPointToRoofWallFace, +} from './roof-segment-walls' + +function segment(overrides: Partial = {}): RoofSegmentNode { + return RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 2.6, + wallThickness: 0.1, + pitch: 40, + ...overrides, + }) +} + +describe('roof wall face frames', () => { + test('frame z = 0 lands on the nominal footprint (wall mid-plane)', () => { + const seg = segment() + // front face, u at the face middle, v = 1, mid-plane. + const point = roofFacePointToSegment(seg, 'front', [(8 + 0.1) / 2, 1, 0]) + expect(point[0]).toBeCloseTo(0) + expect(point[1]).toBeCloseTo(1) + expect(point[2]).toBeCloseTo(3) // depth / 2 — the footprint plane + }) + + test('frame +z is the outward normal on every face', () => { + const seg = segment() + for (const [faceId, axis, sign] of [ + ['front', 2, 1], + ['back', 2, -1], + ['right', 0, 1], + ['left', 0, -1], + ] as const) { + const onPlane = roofFacePointToSegment(seg, faceId, [1, 1, 0]) + const pushed = roofFacePointToSegment(seg, faceId, [1, 1, 0.5]) + expect(pushed[axis] - onPlane[axis]).toBeCloseTo(0.5 * sign) + // The other horizontal axis is unaffected by the push. + const other = axis === 2 ? 0 : 2 + expect(pushed[other] - onPlane[other]).toBeCloseTo(0) + } + }) + + test('face frame agrees with the hit resolver coordinates', () => { + const seg = segment() + // A point on the outer surface (z = +thickness/2 off the mid-plane) + // must read back with the same u/v and dist ≈ 0 off the outer plane. + const segLocal = roofFacePointToSegment(seg, 'right', [2.5, 1.25, 0.05]) + const { u, v, dist } = segmentPointToRoofWallFace(seg, 'right', segLocal) + expect(u).toBeCloseTo(2.5) + expect(v).toBeCloseTo(1.25) + expect(dist).toBeCloseTo(0) + }) + + test('resizing the segment moves the frame, not the stored coords', () => { + // The core live-tracking property: the same face-local point maps to + // the new plane after a depth change — children follow by re-render. + const before = roofFacePointToSegment(segment(), 'front', [2, 1, 0]) + const after = roofFacePointToSegment(segment({ depth: 8 }), 'front', [2, 1, 0]) + expect(before[2]).toBeCloseTo(3) + expect(after[2]).toBeCloseTo(4) + expect(after[1]).toBeCloseTo(before[1]) + }) + + test('frame yaw matches the face descriptor yaw', () => { + const seg = segment() + for (const faceId of ['front', 'back', 'right', 'left'] as const) { + expect(getRoofWallFaceFrame(seg, faceId).yaw).toBe(getRoofSegmentWallFace(seg, faceId).yaw) + } + }) + + test('dutch width-axis roofs expose hostable gable-end wall profiles on the short ends', () => { + const faces = getRoofSegmentWallFaces( + segment({ + roofType: 'dutch', + width: 8, + depth: 6, + dutchHipWidthRatio: 0.25, + dutchWaistLengthRatio: 1, + }), + ) + + const left = faces.find((face) => face.id === 'left') + const right = faces.find((face) => face.id === 'right') + + expect(left?.profile.length).toBeGreaterThan(5) + expect(right?.profile.length).toBeGreaterThan(5) + expect(left?.profile[3]?.[1]).toBeCloseTo(left?.profile[2]?.[1] ?? 0) + }) + + test('dutch long-side faces stay rectangular while only the gable ends rise above the eave', () => { + const faces = getRoofSegmentWallFaces( + segment({ + roofType: 'dutch', + width: 8, + depth: 6, + }), + ) + + const front = faces.find((face) => face.id === 'front') + const back = faces.find((face) => face.id === 'back') + + expect(front?.profile).toHaveLength(4) + expect(back?.profile).toHaveLength(4) + }) +}) diff --git a/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-walls.ts b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-walls.ts new file mode 100644 index 000000000..d6fa9c5bc --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -0,0 +1,453 @@ +import type { RoofSegmentNode } from './roof-segment' +import { getDutchRoofMetrics, getSegmentSlopeFrame } from './roof-segment' + +/** + * Wall-face math for roof segments — the vertical surfaces a wall-mounted + * opening (door / window) can attach to. A segment's generated volume has + * four vertical faces; on gable-family roofs the end faces extend past the + * eave line into the gable (rect + triangle/pentagon, coplanar with the + * base wall). These helpers describe each face as a 2D frame + * (`u` along the face, `v` height above the segment base) plus the + * placeable profile polygon, so placement tools, renderers, and CSG cut + * builders all share one definition of "the wall under the roof". + * + * The numbers MUST mirror the outer wall volume built by + * `getRoofSegmentBrushes` in the viewer's roof system + * (`getVol(wallThickness / 2, 0, 0, …)`): the volume is the segment + * footprint extended outward by `wallThickness / 2`, which drops the eave + * line by `(wallThickness / 2) · tanθ` and raises the ridge by the same + * amount so the apex stays at `wallHeight + activeRh`. + */ + +export type RoofWallFaceId = 'front' | 'back' | 'right' | 'left' + +export type RoofSegmentWallFace = { + id: RoofWallFaceId + /** Outward normal in segment-local space. */ + normal: [number, number, number] + /** + * Yaw (radians, rotation-y) mapping opening-local +Z to the outward + * normal and opening-local +X to the face's +U direction — the same + * frame a wall-hosted door/window uses relative to its wall mesh. + */ + yaw: number + /** Face length along U. */ + length: number + /** + * Placeable region, CCW polygon in face coords. `u ∈ [0, length]`, + * `v` is height above the segment base (segment-local Y). + */ + profile: [number, number][] +} + +type SegmentWallInputs = Pick< + RoofSegmentNode, + 'roofType' | 'width' | 'depth' | 'wallHeight' | 'wallThickness' | 'pitch' +> & + Partial< + Pick< + RoofSegmentNode, + | 'gambrelLowerWidthRatio' + | 'gambrelLowerHeightRatio' + | 'mansardSteepWidthRatio' + | 'mansardSteepHeightRatio' + | 'dutchHipWidthRatio' + | 'dutchHipHeightRatio' + | 'dutchWaistLengthRatio' + > + > + +type WallVolumeFrame = { + /** Outer wall plane extents (footprint + wallThickness). */ + wV: number + dV: number + /** Eave height of the outer volume. */ + eaveY: number + /** Ridge/peak height of the outer volume. */ + peakY: number + /** tan(pitch) of the primary slope. */ + tanTheta: number + hasSlope: boolean +} + +function getWallVolumeFrame(node: SegmentWallInputs): WallVolumeFrame { + const { activeRh, tanTheta } = getSegmentSlopeFrame(node) + const wallThickness = node.wallThickness ?? 0.1 + const autoDrop = (wallThickness / 2) * tanTheta + const wV = Math.max(0.01, node.width + wallThickness) + const dV = Math.max(0.01, node.depth + wallThickness) + const eaveY = Math.max(0.01, node.wallHeight - autoDrop) + let rh = activeRh + if (activeRh > 0) { + rh = activeRh + autoDrop + if (node.roofType === 'shed') rh = activeRh + 2 * autoDrop + } + return { + wV, + dV, + eaveY, + peakY: eaveY + Math.max(0.001, rh), + tanTheta, + hasSlope: activeRh > 0, + } +} + +const FACE_NORMALS: Record = { + front: [0, 0, 1], + back: [0, 0, -1], + right: [1, 0, 0], + left: [-1, 0, 0], +} + +const FACE_YAWS: Record = { + front: 0, + back: Math.PI, + right: Math.PI / 2, + left: -Math.PI / 2, +} + +function rectProfile(length: number, top: number): [number, number][] { + return [ + [0, 0], + [length, 0], + [length, top], + [0, top], + ] +} + +function buildFaceProfile( + node: SegmentWallInputs, + frame: WallVolumeFrame, + id: RoofWallFaceId, +): [number, number][] { + const { wV, dV, eaveY, peakY, tanTheta, hasSlope } = frame + const isEnd = id === 'right' || id === 'left' + const length = isEnd ? dV : wV + + if (!hasSlope) return rectProfile(length, eaveY) + + switch (node.roofType) { + case 'gable': { + if (!isEnd) return rectProfile(length, eaveY) + return [ + [0, 0], + [length, 0], + [length, eaveY], + [length / 2, peakY], + [0, eaveY], + ] + } + case 'gambrel': { + if (!isEnd) return rectProfile(length, eaveY) + // Kink ring sits at z = ±mz on the nominal footprint (see + // getModuleFaces); both end faces are symmetric about u = length/2. + const ratio = node.gambrelLowerWidthRatio ?? 0.5 + const mz = Math.min((node.depth / 2) * ratio, length / 2) + const kinkY = eaveY + (length / 2 - mz) * tanTheta + return [ + [0, 0], + [length, 0], + [length, eaveY], + [length / 2 + mz, kinkY], + [length / 2, peakY], + [length / 2 - mz, kinkY], + [0, eaveY], + ] + } + case 'shed': { + // Slope falls toward +Z: 'back' is the full-height wall, the end + // faces are right trapezoids rising toward the back edge. + if (id === 'front') return rectProfile(length, eaveY) + if (id === 'back') return rectProfile(length, peakY) + if (id === 'right') { + return [ + [0, 0], + [length, 0], + [length, peakY], + [0, eaveY], + ] + } + return [ + [0, 0], + [length, 0], + [length, eaveY], + [0, peakY], + ] + } + case 'dutch': { + const metrics = getDutchRoofMetrics(node) + const isDutchGableFace = + (metrics.axis === 'x' && isEnd) || + (metrics.axis === 'z' && (id === 'front' || id === 'back')) + if (!isDutchGableFace) return rectProfile(length, eaveY) + + const shoulderInset = + metrics.axis === 'x' ? metrics.shoulderInsetAlongDepth : metrics.shoulderInsetAlongWidth + if (!(shoulderInset > 0.001)) return rectProfile(length, eaveY) + + const shoulderLo = Math.max(0, shoulderInset) + const shoulderHi = Math.min(length, length - shoulderInset) + if (!(shoulderHi - shoulderLo > 0.02)) return rectProfile(length, eaveY) + + return [ + [0, 0], + [length, 0], + [length, eaveY], + [shoulderHi, eaveY], + [length / 2, peakY], + [shoulderLo, eaveY], + [0, eaveY], + ] + } + default: + return rectProfile(length, eaveY) + } +} + +export function getRoofSegmentWallFace( + node: SegmentWallInputs, + id: RoofWallFaceId, +): RoofSegmentWallFace { + const frame = getWallVolumeFrame(node) + const isEnd = id === 'right' || id === 'left' + return { + id, + normal: FACE_NORMALS[id], + yaw: FACE_YAWS[id], + length: isEnd ? frame.dV : frame.wV, + profile: buildFaceProfile(node, frame, id), + } +} + +export function getRoofSegmentWallFaces(node: SegmentWallInputs): RoofSegmentWallFace[] { + const frame = getWallVolumeFrame(node) + return (['front', 'back', 'right', 'left'] as const).map((id) => ({ + id, + normal: FACE_NORMALS[id], + yaw: FACE_YAWS[id], + length: id === 'right' || id === 'left' ? frame.dV : frame.wV, + profile: buildFaceProfile(node, frame, id), + })) +} + +/** + * Segment-local point → face coords. `dist` is the signed offset off the + * outer wall plane along the face normal (0 = on the plane, positive = + * outside the volume). + */ +export function segmentPointToRoofWallFace( + node: SegmentWallInputs, + id: RoofWallFaceId, + point: [number, number, number], +): { u: number; v: number; dist: number } { + const { wV, dV } = getWallVolumeFrame(node) + const [x, y, z] = point + switch (id) { + case 'front': + return { u: x + wV / 2, v: y, dist: z - dV / 2 } + case 'back': + return { u: wV / 2 - x, v: y, dist: -z - dV / 2 } + case 'right': + return { u: dV / 2 - z, v: y, dist: x - wV / 2 } + case 'left': + return { u: z + dV / 2, v: y, dist: -x - wV / 2 } + } +} + +type FaceConstraint = { + nu: number + nv: number + c: number +} + +/** + * Inward half-plane constraints of the raw profile polygon (CCW → + * interior is to the left of each edge): a point p is inside when + * `nu·p.u + nv·p.v ≥ c` for every constraint. + */ +function getProfileConstraints(face: RoofSegmentWallFace): FaceConstraint[] { + const constraints: FaceConstraint[] = [] + const pts = face.profile + for (let i = 0; i < pts.length; i++) { + const a = pts[i]! + const b = pts[(i + 1) % pts.length]! + const du = b[0] - a[0] + const dv = b[1] - a[1] + const len = Math.hypot(du, dv) + if (len < 1e-9) continue + const nu = -dv / len + const nv = du / len + constraints.push({ nu, nv, c: nu * a[0] + nv * a[1] }) + } + return constraints +} + +/** + * Half-plane constraints for the CENTER of a `width × height` rect that + * must fit inside the face profile — the raw constraints eroded by the + * rect's half-extents projected on each edge normal. + */ +function getRectCenterConstraints( + face: RoofSegmentWallFace, + width: number, + height: number, +): FaceConstraint[] { + return getProfileConstraints(face).map(({ nu, nv, c }) => ({ + nu, + nv, + c: c + (Math.abs(nu) * width) / 2 + (Math.abs(nv) * height) / 2, + })) +} + +/** + * The face's render frame in segment-local space: a group placed at + * `origin` and yawed by `yaw` maps face coords to segment space — + * frame X = U (along the face), frame Y = V (height), frame Z = the + * outward normal, with z = 0 on the WALL MID-PLANE. The mid-plane of + * the generated wall volume lands exactly on the nominal footprint + * (`±width/2` / `±depth/2`), so hosted children use the same position + * conventions as wall children (openings at z = 0, wall-side items + * pushed +thickness/2 at render time). Renderers derive this from the + * live-override-merged segment, which is what makes hosted children + * track segment edits live instead of jumping on commit. + */ +export function getRoofWallFaceFrame( + node: SegmentWallInputs, + id: RoofWallFaceId, +): { origin: [number, number, number]; yaw: number } { + const { wV, dV } = getWallVolumeFrame(node) + switch (id) { + case 'front': + return { origin: [-wV / 2, 0, node.depth / 2], yaw: FACE_YAWS.front } + case 'back': + return { origin: [wV / 2, 0, -node.depth / 2], yaw: FACE_YAWS.back } + case 'right': + return { origin: [node.width / 2, 0, dV / 2], yaw: FACE_YAWS.right } + case 'left': + return { origin: [-node.width / 2, 0, -dV / 2], yaw: FACE_YAWS.left } + } +} + +/** Face-frame point ([u, v, z-from-mid-plane]) → segment-local point. */ +export function roofFacePointToSegment( + node: SegmentWallInputs, + id: RoofWallFaceId, + point: [number, number, number], +): [number, number, number] { + const { origin, yaw } = getRoofWallFaceFrame(node, id) + const cos = Math.cos(yaw) + const sin = Math.sin(yaw) + const [u, v, z] = point + // rotation-y: +x → (cos, 0, -sin), +z → (sin, 0, cos) + return [origin[0] + u * cos + z * sin, origin[1] + v, origin[2] - u * sin + z * cos] +} + +/** + * Max width of a rect growing from an anchored vertical edge (`anchorU`) + * in direction `growSign` (±1 along U) while staying inside the face + * profile at the fixed vertical center `vCenter`. Resize-handle limit: + * the anchored-edge model matches the handles' apply math (opposite + * edge stays put, center re-derives). + */ +export function getMaxRoofRectWidthFromAnchor( + face: RoofSegmentWallFace, + anchorU: number, + growSign: number, + vCenter: number, + height: number, +): number { + let max = Number.POSITIVE_INFINITY + for (const { nu, nv, c } of getProfileConstraints(face)) { + // Center at anchorU + growSign·w/2, eroded by |nu|·w/2 + |nv|·h/2: + // base + k·w ≥ 0 with k ≤ 0 only when growth approaches the edge. + const k = (nu * growSign - Math.abs(nu)) / 2 + if (k >= -1e-9) continue + const base = nu * anchorU + nv * vCenter - c - (Math.abs(nv) * height) / 2 + max = Math.min(max, Math.max(0, base / -k)) + } + return max +} + +/** + * Max height of a rect growing from an anchored horizontal edge + * (`anchorV`) in direction `growSign` (+1 = bottom anchored, grows up) + * while staying inside the face profile at the fixed horizontal center + * `uCenter`. + */ +export function getMaxRoofRectHeightFromAnchor( + face: RoofSegmentWallFace, + uCenter: number, + width: number, + anchorV: number, + growSign: number, +): number { + let max = Number.POSITIVE_INFINITY + for (const { nu, nv, c } of getProfileConstraints(face)) { + const k = (nv * growSign - Math.abs(nv)) / 2 + if (k >= -1e-9) continue + const base = nu * uCenter + nv * anchorV - c - (Math.abs(nu) * width) / 2 + max = Math.min(max, Math.max(0, base / -k)) + } + return max +} + +const CLAMP_EPSILON = 1e-4 + +/** + * Clamp a rect center so the rect fits inside the face profile. + * + * - `lockV: true` (doors): `v` is fixed; only `u` slides. Returns null + * when no `u` keeps the rect inside at that height. + * - otherwise (windows): the center is projected into the eroded convex + * region (cyclic projection — profiles are convex by construction). + * + * Returns null when the rect cannot fit anywhere on the face. + */ +export function clampRectToRoofWallFace( + face: RoofSegmentWallFace, + u: number, + v: number, + width: number, + height: number, + opts?: { lockV?: boolean }, +): { u: number; v: number } | null { + const constraints = getRectCenterConstraints(face, width, height) + if (constraints.length < 3) return null + + if (opts?.lockV) { + let lo = Number.NEGATIVE_INFINITY + let hi = Number.POSITIVE_INFINITY + for (const { nu, nv, c } of constraints) { + const rhs = c - nv * v + if (Math.abs(nu) < 1e-9) { + if (rhs > CLAMP_EPSILON) return null + continue + } + if (nu > 0) lo = Math.max(lo, rhs / nu) + else hi = Math.min(hi, rhs / nu) + } + if (lo > hi + CLAMP_EPSILON) return null + return { u: Math.min(Math.max(u, lo), hi), v } + } + + let pu = u + let pv = v + for (let iter = 0; iter < 32; iter++) { + let worst: FaceConstraint | null = null + let worstViolation = CLAMP_EPSILON + for (const constraint of constraints) { + const violation = constraint.c - (constraint.nu * pu + constraint.nv * pv) + if (violation > worstViolation) { + worstViolation = violation + worst = constraint + } + } + if (!worst) return { u: pu, v: pv } + pu += worst.nu * worstViolation + pv += worst.nv * worstViolation + } + for (const { nu, nv, c } of constraints) { + if (nu * pu + nv * pv < c - 1e-3) return null + } + return { u: pu, v: pv } +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/roof-segment.ts b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment.ts new file mode 100644 index 000000000..2f13fdc2a --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/roof-segment.ts @@ -0,0 +1,761 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import type { MaterialSchema as MaterialSchemaType } from '../material' +import { MaterialSchema } from '../material' + +export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat']) + +export type RoofType = z.infer + +export const MIN_ROOF_SEGMENT_TRIM_SPAN = 0.1 +const DEFAULT_ROOF_SEGMENT_WIDTH = 8 +const DEFAULT_ROOF_SEGMENT_DEPTH = 6 + +export const RoofSegmentTrim = z + .object({ + left: z.number().min(0).default(0), + right: z.number().min(0).default(0), + front: z.number().min(0).default(0), + back: z.number().min(0).default(0), + frontLeft: z.number().min(0).default(0), + frontRight: z.number().min(0).default(0), + backLeft: z.number().min(0).default(0), + backRight: z.number().min(0).default(0), + frontLeftX: z.number().min(0).default(0), + frontLeftZ: z.number().min(0).default(0), + frontRightX: z.number().min(0).default(0), + frontRightZ: z.number().min(0).default(0), + backLeftX: z.number().min(0).default(0), + backLeftZ: z.number().min(0).default(0), + backRightX: z.number().min(0).default(0), + backRightZ: z.number().min(0).default(0), + }) + .default({ + left: 0, + right: 0, + front: 0, + back: 0, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, + }) + +export type RoofSegmentTrim = z.infer + +// Default shape ratios. Tuning these used to require editing the geometry +// code in two places; they are now schema fields with these defaults. +export const ROOF_SHAPE_DEFAULTS = { + /** Gambrel: lower (steep) face occupies this fraction of the horizontal half-depth. */ + gambrelLowerWidthRatio: 0.5, + /** Gambrel: lower (steep) face rises this fraction of the way to the peak. */ + gambrelLowerHeightRatio: 0.6, + /** Mansard: steep face occupies this fraction of `min(width, depth)`. */ + mansardSteepWidthRatio: 0.15, + /** Mansard: steep face rises this fraction of the way to the peak. */ + mansardSteepHeightRatio: 0.7, + /** Dutch: hip face occupies this fraction of `min(width, depth)`. */ + dutchHipWidthRatio: 0.25, + /** Dutch: hip face rises this fraction of the way to the peak. */ + dutchHipHeightRatio: 0.5, + /** Dutch: gable waist span along the ridge axis, as a fraction of the max span. */ + dutchWaistLengthRatio: 0.98, + /** + * Dutch: how far the gablet's barge board extends outward past the gablet + * end-wall, along the ridge axis, in metres. 0 disables the rake. The board + * lies in the gablet's slope planes (coplanar with the main Dutch slopes) + * and overhangs the lower hip skirt; the gablet end-wall itself stays put. + */ + dutchGabletRake: 0.48, + /** Dutch: thickness of the top gable rake slab. */ + dutchTopRakeThickness: 0.21, +} as const + +export const RoofSegmentNode = BaseNode.extend({ + id: objectId('rseg'), + type: nodeType('roof-segment'), + // Catch-all material — splatted across all 4 slots in the renderer + // when no role-specific override is set. Kept for back-compat with + // older scenes; new paint operations should prefer the role fields. + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + // Role-specific overrides mirror the parent roof's surface roles. + // When set they win over the segment's catch-all `material` and over + // the parent roof's role / catch-all materials. Resolution order: + // segment role → segment catch-all → roof role → roof catch-all. + topMaterial: MaterialSchema.optional(), + topMaterialPreset: z.string().optional(), + edgeMaterial: MaterialSchema.optional(), + edgeMaterialPreset: z.string().optional(), + wallMaterial: MaterialSchema.optional(), + wallMaterialPreset: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around Y axis in radians + rotation: z.number().default(0), + // Roof shape type + roofType: RoofType.default('gable'), + // Footprint dimensions + width: z.number().default(8), + depth: z.number().default(6), + // Segment-local distances trimmed from each footprint side. The trim + // boundary is projected vertically through the roof volume, so the + // resulting edge follows the actual sloped roof surfaces. + trim: RoofSegmentTrim, + // Wall height beneath the roof + wallHeight: z.number().default(0.5), + // Roof pitch in degrees — angle of the primary slope face. + // For gable/hip/shed this is the only slope; for gambrel/mansard/dutch + // it is the steep (lower) slope. The overall peak height is derived + // from pitch + footprint + roofType via getActiveRoofHeight(). + pitch: z.number().min(0).max(85).default(40), + // Structure thicknesses + wallThickness: z.number().default(0.1), + deckThickness: z.number().default(0.1), + overhang: z.number().default(0.3), + shingleThickness: z.number().default(0.05), + // Shape-specific ratios. Only the pair matching `roofType` is read; the + // rest are inert. Defined on every segment so the panel can flip + // roofType without losing the previous shape's tuning. + gambrelLowerWidthRatio: z + .number() + .min(0.1) + .max(0.9) + .default(ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio), + gambrelLowerHeightRatio: z + .number() + .min(0.1) + .max(0.9) + .default(ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio), + mansardSteepWidthRatio: z + .number() + .min(0.05) + .max(0.45) + .default(ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio), + mansardSteepHeightRatio: z + .number() + .min(0.1) + .max(0.9) + .default(ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio), + dutchHipWidthRatio: z + .number() + .min(0.05) + .max(0.45) + .default(ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio), + dutchHipHeightRatio: z + .number() + .min(0.1) + .max(0.9) + .default(ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio), + dutchWaistLengthRatio: z + .number() + .min(0.1) + .max(1) + .default(ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio), + dutchGabletRake: z.number().min(0).max(3).default(ROOF_SHAPE_DEFAULTS.dutchGabletRake), + dutchTopRakeThickness: z + .number() + .min(0.01) + .max(0.5) + .default(ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness), + // Hosted accessories — chimney, dormer, skylight, box-vent, + // ridge-vent, solar-panel, gutter. Each accessory's `parentId` points back + // here; the segment renderer mounts them recursively via + // `` so they inherit the segment's transform stack. + // Required for `createNode(child, segmentId)` to append the child + // to this array — see + // `wiki/architecture/node-definitions.md` ("Host kinds need a + // `children` field on the schema"). + children: z.array(z.string()).default([]), +}).describe( + dedent` + Roof segment node - an individual roof module within a roof group. + Each segment generates a complete architectural volume (walls + roof). + Multiple segments can be combined to form complex roof shapes. + - roofType: hip, gable, shed, gambrel, dutch, mansard, flat + - width/depth: footprint dimensions + - trim: segment-local side cut distances + - wallHeight: height of walls below the roof + - pitch: roof slope in degrees (angle of the primary slope face) + - wallThickness/deckThickness: structural thicknesses + - overhang: eave overhang distance + - shingleThickness: outer shingle layer thickness + - gambrelLowerWidthRatio / gambrelLowerHeightRatio: kink position on gambrel roofs + - mansardSteepWidthRatio / mansardSteepHeightRatio: waist position on mansard roofs + - dutchHipWidthRatio / dutchHipHeightRatio: hip-to-gable split on dutch roofs + - dutchWaistLengthRatio: gable waist span along the ridge axis + - dutchGabletRake: gablet barge-board overhang past the gablet end-wall (m, 0 = none) + - dutchTopRakeThickness: thickness of the top gable rake slab + `, +) + +export type RoofSegmentNode = z.infer + +function finiteNonNegative(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} + +function finitePositive(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback +} + +function normalizeTrimAxis(start: unknown, end: unknown, span: number): readonly [number, number] { + const maxTotal = Math.max(0, finiteNonNegative(span) - MIN_ROOF_SEGMENT_TRIM_SPAN) + let a = Math.min(finiteNonNegative(start), maxTotal) + let b = Math.min(finiteNonNegative(end), maxTotal) + const total = a + b + + if (total > maxTotal && total > 0) { + const scale = maxTotal / total + a *= scale + b *= scale + } + + return [a, b] as const +} + +export function normalizeRoofSegmentTrim( + node: Pick & { trim?: Partial }, +): RoofSegmentTrim { + const trim = node.trim ?? {} + const [left, right] = normalizeTrimAxis(trim.left, trim.right, node.width) + const [back, front] = normalizeTrimAxis(trim.back, trim.front, node.depth) + const maxWidthDiagonal = Math.max(0, finiteNonNegative(node.width) - left - right) + const maxDepthDiagonal = Math.max(0, finiteNonNegative(node.depth) - front - back) + const maxWidthPair = Math.max(0, maxWidthDiagonal - MIN_ROOF_SEGMENT_TRIM_SPAN) + const maxDepthPair = Math.max(0, maxDepthDiagonal - MIN_ROOF_SEGMENT_TRIM_SPAN) + let [frontLeftX, frontLeftZ] = normalizeCornerAxisTrim( + trim.frontLeft, + trim.frontLeftX, + trim.frontLeftZ, + maxWidthPair, + maxDepthPair, + ) + let [frontRightX, frontRightZ] = normalizeCornerAxisTrim( + trim.frontRight, + trim.frontRightX, + trim.frontRightZ, + maxWidthPair, + maxDepthPair, + ) + let [backLeftX, backLeftZ] = normalizeCornerAxisTrim( + trim.backLeft, + trim.backLeftX, + trim.backLeftZ, + maxWidthPair, + maxDepthPair, + ) + let [backRightX, backRightZ] = normalizeCornerAxisTrim( + trim.backRight, + trim.backRightX, + trim.backRightZ, + maxWidthPair, + maxDepthPair, + ) + + for (let i = 0; i < 3; i += 1) { + ;[frontLeftX, frontRightX] = normalizeTrimPair(frontLeftX, frontRightX, maxWidthPair) + ;[backLeftX, backRightX] = normalizeTrimPair(backLeftX, backRightX, maxWidthPair) + ;[frontLeftZ, backLeftZ] = normalizeTrimPair(frontLeftZ, backLeftZ, maxDepthPair) + ;[frontRightZ, backRightZ] = normalizeTrimPair(frontRightZ, backRightZ, maxDepthPair) + } + + const frontLeft = Math.min(frontLeftX, frontLeftZ) + const frontRight = Math.min(frontRightX, frontRightZ) + const backLeft = Math.min(backLeftX, backLeftZ) + const backRight = Math.min(backRightX, backRightZ) + + return { + left, + right, + front, + back, + frontLeft, + frontRight, + backLeft, + backRight, + frontLeftX, + frontLeftZ, + frontRightX, + frontRightZ, + backLeftX, + backLeftZ, + backRightX, + backRightZ, + } +} + +function normalizeTrimPair(a: number, b: number, maxTotal: number): [number, number] { + const total = a + b + if (total <= maxTotal || total <= 0) return [a, b] + const scale = maxTotal / total + return [a * scale, b * scale] +} + +function normalizeCornerAxisTrim( + scalar: unknown, + axisX: unknown, + axisZ: unknown, + maxX: number, + maxZ: number, +): [number, number] { + const x = finiteNonNegative(axisX) + const z = finiteNonNegative(axisZ) + const fallback = finiteNonNegative(scalar) + if (x > 0 || z > 0) { + return [Math.min(x, maxX), Math.min(z, maxZ)] + } + return [Math.min(fallback, maxX), Math.min(fallback, maxZ)] +} + +// ---------------------------------------------------------------------------- +// Pitch ↔ roof-peak height +// +// Pitch is the angle of the primary slope face. For each roof type the +// "primary slope" maps to a specific (rise, run) pair on the constructed +// geometry — gambrel/mansard/dutch have a multi-face slope and we standardise +// on the lower / steep face. These helpers are the single conversion point; +// all geometry consumers should call `getActiveRoofHeight` instead of reading +// a stored roofHeight field. +// ---------------------------------------------------------------------------- + +/** Shape of the per-type ratios consumed by the slope helpers. */ +type ShapeRatios = { + gambrelLowerWidthRatio: number + gambrelLowerHeightRatio: number + mansardSteepWidthRatio: number + mansardSteepHeightRatio: number + dutchHipWidthRatio: number + dutchHipHeightRatio: number + dutchWaistLengthRatio: number +} + +type PitchInputs = { + roofType: RoofType + width: number + depth: number +} & Partial + +export type DutchRoofMetrics = { + axis: 'x' | 'z' + inset: number + waistHalfX: number + waistHalfZ: number + ridgeStart: readonly [number, number] + ridgeEnd: readonly [number, number] + shoulderInsetAlongDepth: number + shoulderInsetAlongWidth: number +} + +function getDutchUpperShellBounds( + node: Pick & + Partial< + Pick + >, +) { + const metrics = getDutchRoofMetrics(node) + const width = finitePositive(node.width, DEFAULT_ROOF_SEGMENT_WIDTH) + const depth = finitePositive(node.depth, DEFAULT_ROOF_SEGMENT_DEPTH) + const rake = node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake + const rakeReach = + metrics.axis === 'x' + ? Math.min(Math.max(0, rake), Math.max(0, width / 2 - metrics.waistHalfX) * 0.98) + : Math.min(Math.max(0, rake), Math.max(0, depth / 2 - metrics.waistHalfZ) * 0.98) + + return { + ...metrics, + upperHalfX: metrics.axis === 'x' ? metrics.waistHalfX + rakeReach : metrics.waistHalfX, + upperHalfZ: metrics.axis === 'x' ? metrics.waistHalfZ : metrics.waistHalfZ + rakeReach, + } +} + +function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios { + return { + ...input, + width: finitePositive(input.width, DEFAULT_ROOF_SEGMENT_WIDTH), + depth: finitePositive(input.depth, DEFAULT_ROOF_SEGMENT_DEPTH), + gambrelLowerWidthRatio: + input.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, + gambrelLowerHeightRatio: + input.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio, + mansardSteepWidthRatio: + input.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio, + mansardSteepHeightRatio: + input.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio, + dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + dutchWaistLengthRatio: input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + } +} + +export function getDutchRoofMetrics( + input: Pick & + Partial>, +): DutchRoofMetrics { + const width = finitePositive(input.width, DEFAULT_ROOF_SEGMENT_WIDTH) + const depth = finitePositive(input.depth, DEFAULT_ROOF_SEGMENT_DEPTH) + const inset = + Math.min(width, depth) * (input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio) + const waistLengthRatio = input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio + + if (width >= depth) { + const waistHalfX = Math.max(0, (width / 2 - inset) * waistLengthRatio) + const waistHalfZ = Math.max(0, depth / 2 - inset) + return { + axis: 'x', + inset, + waistHalfX, + waistHalfZ, + ridgeStart: [-waistHalfX, 0], + ridgeEnd: [waistHalfX, 0], + shoulderInsetAlongDepth: Math.max(0, depth / 2 - waistHalfZ), + shoulderInsetAlongWidth: Math.max(0, width / 2 - waistHalfX), + } + } + + const waistHalfX = Math.max(0, width / 2 - inset) + const waistHalfZ = Math.max(0, (depth / 2 - inset) * waistLengthRatio) + return { + axis: 'z', + inset, + waistHalfX, + waistHalfZ, + ridgeStart: [0, waistHalfZ], + ridgeEnd: [0, -waistHalfZ], + shoulderInsetAlongDepth: Math.max(0, depth / 2 - waistHalfZ), + shoulderInsetAlongWidth: Math.max(0, width / 2 - waistHalfX), + } +} + +function getPrimarySlopeRun(input: PitchInputs & ShapeRatios): number { + const min = Math.min(input.width, input.depth) + switch (input.roofType) { + case 'shed': + return input.depth + case 'gable': + return input.depth / 2 + case 'gambrel': + return (input.depth / 2) * input.gambrelLowerWidthRatio + case 'mansard': + return min * input.mansardSteepWidthRatio + case 'dutch': + return min * input.dutchHipWidthRatio + default: + return min / 2 + } +} + +// Fraction of the overall peak height that is taken up by the primary slope. +function getPrimarySlopeRiseFraction(input: PitchInputs & ShapeRatios): number { + switch (input.roofType) { + case 'gambrel': + return input.gambrelLowerHeightRatio + case 'mansard': + return input.mansardSteepHeightRatio + case 'dutch': + return input.dutchHipHeightRatio + default: + return 1 + } +} + +export type SegmentSlopeFrame = { + /** Horizontal half-span of the primary slope face (eave-to-ridge). */ + run: number + /** Vertical height of the primary slope face. */ + rise: number + /** tan(pitch). 0 for flat or zero-pitch segments. */ + tanTheta: number + /** cos(pitch). 1 for flat or zero-pitch segments. */ + cosTheta: number + /** sin(pitch). 0 for flat or zero-pitch segments. */ + sinTheta: number + /** Overall eave-to-peak height of the assembled roof. */ + activeRh: number +} + +/** + * One stop for the slope math every roof-segment consumer needs. Builds + * `run`, `rise`, the trig triple, and the overall peak height from the + * segment's pitch + footprint + roofType. Before this helper existed, + * the table was duplicated in three places (the brush builder, the + * skylight surface-frame routine, and the segment-hit raycaster) and + * silently drifted when a new roof type was added. + */ +export function getSegmentSlopeFrame( + node: Pick & Partial, +): SegmentSlopeFrame { + const ratios = withRatioDefaults(node) + const run = getPrimarySlopeRun(ratios) + // `!(pitch > 0)` (not `pitch <= 0`) so a missing/NaN pitch — e.g. a segment + // from an older migration that only set `roofHeight`, or stale persisted data — + // resolves to a flat frame instead of computing `Math.tan(NaN)` → NaN geometry, + // which poisons the merged-roof CSG ("Coplanar clip not handled" + NaN positions). + if (node.roofType === 'flat' || !(node.pitch > 0)) { + return { run, rise: 0, tanTheta: 0, cosTheta: 1, sinTheta: 0, activeRh: 0 } + } + const pitchRad = (node.pitch * Math.PI) / 180 + const tanTheta = Math.tan(pitchRad) + const cosTheta = Math.cos(pitchRad) || 1 + const sinTheta = Math.sin(pitchRad) + const rise = run * tanTheta + const activeRh = rise / getPrimarySlopeRiseFraction(ratios) + return { run, rise, tanTheta, cosTheta, sinTheta, activeRh } +} + +/** + * The eave-to-peak height of the assembled segment, derived from pitch + + * footprint + roofType. Replaces the legacy `roofHeight` field on the node. + */ +export function getActiveRoofHeight(node: Parameters[0]): number { + return getSegmentSlopeFrame(node).activeRh +} + +export type RoofSegmentVisibleTopBounds = { + minX: number + maxX: number + minZ: number + maxZ: number + width: number + depth: number +} + +export function getRoofSegmentVisibleTopBounds( + segment: RoofSegmentNode, +): RoofSegmentVisibleTopBounds { + const { activeRh, cosTheta, sinTheta } = getSegmentSlopeFrame(segment) + const width = finitePositive(segment.width, DEFAULT_ROOF_SEGMENT_WIDTH) + const depth = finitePositive(segment.depth, DEFAULT_ROOF_SEGMENT_DEPTH) + const trim = normalizeRoofSegmentTrim({ ...segment, width, depth }) + const horizontalOverhang = finiteNonNegative(segment.overhang) * cosTheta + const deckExt = finiteNonNegative(segment.wallThickness) / 2 + horizontalOverhang + const shingleOverhang = finiteNonNegative(segment.shingleThickness) * sinTheta + + let xExt = deckExt + let frontExt = deckExt + let backExt = deckExt + + if ( + segment.roofType === 'hip' || + segment.roofType === 'mansard' || + segment.roofType === 'dutch' + ) { + xExt += shingleOverhang + frontExt += shingleOverhang + backExt += shingleOverhang + } else if (segment.roofType === 'gable' || segment.roofType === 'gambrel') { + frontExt += shingleOverhang + backExt += shingleOverhang + } else if (segment.roofType === 'shed' && activeRh > 0) { + frontExt += shingleOverhang + } + + let minX = trim.left > 0 ? -width / 2 + trim.left : -width / 2 - xExt + let maxX = trim.right > 0 ? width / 2 - trim.right : width / 2 + xExt + let minZ = trim.back > 0 ? -depth / 2 + trim.back : -depth / 2 - backExt + let maxZ = trim.front > 0 ? depth / 2 - trim.front : depth / 2 + frontExt + + if (trim.frontLeftX > 0 && trim.frontLeftZ > 0 && maxZ - trim.frontLeftZ < 0) { + minX = Math.max(minX, minX + (trim.frontLeftX * (trim.frontLeftZ - maxZ)) / trim.frontLeftZ) + } + if (trim.backLeftX > 0 && trim.backLeftZ > 0 && minZ + trim.backLeftZ > 0) { + minX = Math.max(minX, minX + (trim.backLeftX * (trim.backLeftZ + minZ)) / trim.backLeftZ) + } + if (trim.frontRightX > 0 && trim.frontRightZ > 0 && maxZ - trim.frontRightZ < 0) { + maxX = Math.min(maxX, maxX - (trim.frontRightX * (trim.frontRightZ - maxZ)) / trim.frontRightZ) + } + if (trim.backRightX > 0 && trim.backRightZ > 0 && minZ + trim.backRightZ > 0) { + maxX = Math.min(maxX, maxX - (trim.backRightX * (trim.backRightZ + minZ)) / trim.backRightZ) + } + + if (trim.frontLeftX > 0 && trim.frontLeftZ > 0 && minX + trim.frontLeftX > 0) { + maxZ = Math.min(maxZ, maxZ - (trim.frontLeftZ * (trim.frontLeftX + minX)) / trim.frontLeftX) + } + if (trim.frontRightX > 0 && trim.frontRightZ > 0 && maxX - trim.frontRightX < 0) { + maxZ = Math.min(maxZ, maxZ - (trim.frontRightZ * (trim.frontRightX - maxX)) / trim.frontRightX) + } + if (trim.backLeftX > 0 && trim.backLeftZ > 0 && minX + trim.backLeftX > 0) { + minZ = Math.max(minZ, minZ + (trim.backLeftZ * (trim.backLeftX + minX)) / trim.backLeftX) + } + if (trim.backRightX > 0 && trim.backRightZ > 0 && maxX - trim.backRightX < 0) { + minZ = Math.max(minZ, minZ + (trim.backRightZ * (trim.backRightX - maxX)) / trim.backRightX) + } + + return { + minX, + maxX, + minZ, + maxZ, + width: Math.max(0.01, maxX - minX), + depth: Math.max(0.01, maxZ - minZ), + } +} + +/** Segment-local surface height used by roof accessory placement and hit disambiguation. */ +export function getRoofSegmentSurfaceY( + node: Pick & + Parameters[0], + localX: number, + localZ: number, +): number { + const slopeFrame = getSegmentSlopeFrame(node) + const activeRh = slopeFrame.activeRh + const peakY = node.wallHeight + activeRh + if (activeRh === 0) return node.wallHeight + + if (node.roofType === 'gable' || node.roofType === 'gambrel' || node.roofType === 'mansard') { + const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 + return peakY - t * activeRh + } + + if (node.roofType === 'dutch') { + const hipHeightRatio = node.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio + const metrics = getDutchUpperShellBounds(node) + const lowerRise = activeRh * hipHeightRatio + if (metrics.axis === 'x') { + const waistHalfZ = Math.max(0.0001, metrics.waistHalfZ) + if (Math.abs(localX) <= metrics.upperHalfX && Math.abs(localZ) <= waistHalfZ) { + const upperRise = activeRh * (1 - hipHeightRatio) + const upperTan = upperRise / waistHalfZ + return peakY - Math.abs(localZ) * upperTan + } + + const xProgressDenom = Math.max(0.0001, node.width / 2 - metrics.waistHalfX) + const zProgressDenom = Math.max(0.0001, node.depth / 2 - waistHalfZ) + const xProgress = Math.max(0, Math.abs(localX) - metrics.waistHalfX) / xProgressDenom + const zProgress = Math.max(0, Math.abs(localZ) - waistHalfZ) / zProgressDenom + return node.wallHeight + lowerRise * (1 - Math.min(1, Math.max(xProgress, zProgress))) + } + + const waistHalfX = Math.max(0.0001, metrics.waistHalfX) + if (Math.abs(localX) <= waistHalfX && Math.abs(localZ) <= metrics.upperHalfZ) { + const upperRise = activeRh * (1 - hipHeightRatio) + const upperRun = waistHalfX + const upperTan = upperRise / upperRun + return peakY - Math.abs(localX) * upperTan + } + const xProgressDenom = Math.max(0.0001, node.width / 2 - waistHalfX) + const zProgressDenom = Math.max(0.0001, node.depth / 2 - metrics.waistHalfZ) + const xProgress = Math.max(0, Math.abs(localX) - waistHalfX) / xProgressDenom + const zProgress = Math.max(0, Math.abs(localZ) - metrics.waistHalfZ) / zProgressDenom + return node.wallHeight + lowerRise * (1 - Math.min(1, Math.max(xProgress, zProgress))) + } + + if (node.roofType === 'shed') { + const t = (localZ + node.depth / 2) / (node.depth || 1) + return peakY - t * activeRh + } + + if (node.roofType === 'hip') { + const fx = node.width > 0 ? Math.abs(localX) / (node.width / 2) : 0 + const fz = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 + return peakY - Math.max(fx, fz) * activeRh + } + + const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 + return peakY - t * activeRh +} + +/** + * Inverse of `getActiveRoofHeight` — recover the pitch a legacy + * `roofHeight` value would correspond to. Used by the scene migration. + * Ratio overrides are optional and default to the shape defaults. + */ +export function getPitchFromActiveRoofHeight(input: PitchInputs & { roofHeight: number }): number { + if (input.roofType === 'flat' || input.roofHeight <= 0) return 0 + const ratios = withRatioDefaults(input) + const run = getPrimarySlopeRun(ratios) + if (run <= 0) return 0 + const rise = input.roofHeight * getPrimarySlopeRiseFraction(ratios) + return (Math.atan2(rise, run) * 180) / Math.PI +} + +// ---------------------------------------------------------------------------- +// Per-segment surface materials +// ---------------------------------------------------------------------------- + +export type RoofSegmentSurfaceMaterialRole = 'top' | 'edge' | 'wall' +export type RoofSegmentSurfaceMaterialSpec = { + material?: MaterialSchemaType + materialPreset?: string +} + +function getLegacyRoofSegmentSurfaceMaterial( + node: RoofSegmentNode, +): RoofSegmentSurfaceMaterialSpec { + return { + material: node.material, + materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, + } +} + +/** + * Resolve the segment-level material for one of the three surface roles. + * Falls back through: role-specific field → catch-all `material`. Pass the + * parent roof to `parentFallback` when you want the roof's role material + * to fill in for an unset segment slot — typical from the renderer. + */ +export function getEffectiveSegmentSurfaceMaterial( + node: RoofSegmentNode, + role: RoofSegmentSurfaceMaterialRole, + parentFallback?: RoofSegmentSurfaceMaterialSpec, +): RoofSegmentSurfaceMaterialSpec { + if (role === 'top') { + if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') { + return { + material: node.topMaterial, + materialPreset: + typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined, + } + } + } else if (role === 'edge') { + if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { + return { + material: node.edgeMaterial, + materialPreset: + typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined, + } + } + } else if (role === 'wall') { + if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { + return { + material: node.wallMaterial, + materialPreset: + typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined, + } + } + } + + const legacy = getLegacyRoofSegmentSurfaceMaterial(node) + if (legacy.material !== undefined || legacy.materialPreset !== undefined) return legacy + + return parentFallback ?? { material: undefined, materialPreset: undefined } +} + +/** + * Returns true when the segment has any segment-level material override — + * either the legacy catch-all or any of the three role-specific fields. + * Used by `RoofRenderer` and `updateMergedRoofGeometry` to decide whether + * the segment should be drawn as its own mesh or folded into the merged + * shell. + */ +export function hasSegmentMaterialOverride(node: RoofSegmentNode): boolean { + return ( + node.material !== undefined || + typeof node.materialPreset === 'string' || + node.topMaterial !== undefined || + typeof node.topMaterialPreset === 'string' || + node.edgeMaterial !== undefined || + typeof node.edgeMaterialPreset === 'string' || + node.wallMaterial !== undefined || + typeof node.wallMaterialPreset === 'string' + ) +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/roof.ts b/services/pascal-editor/packages/core/src/schema/nodes/roof.ts new file mode 100644 index 000000000..89d806b28 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/roof.ts @@ -0,0 +1,89 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import type { MaterialSchema as MaterialSchemaType } from '../material' +import { MaterialSchema } from '../material' +import { RoofSegmentNode } from './roof-segment' + +export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall' +export type RoofSurfaceMaterialSpec = { + material?: MaterialSchemaType + materialPreset?: string +} + +export const RoofNode = BaseNode.extend({ + id: objectId('roof'), + type: nodeType('roof'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + topMaterial: MaterialSchema.optional(), + topMaterialPreset: z.string().optional(), + edgeMaterial: MaterialSchema.optional(), + edgeMaterialPreset: z.string().optional(), + wallMaterial: MaterialSchema.optional(), + wallMaterialPreset: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around Y axis in radians + rotation: z.number().default(0), + // Child roof segment IDs + children: z.array(RoofSegmentNode.shape.id).default([]), +}).describe( + dedent` + Roof node - a container for roof segments. + Acts as a group that holds one or more RoofSegmentNodes. + When not being edited, segments are visually combined into a single solid. + - position: center position of the roof group + - rotation: rotation around Y axis + - children: array of RoofSegmentNode IDs + `, +) + +export type RoofNode = z.infer + +function getLegacyRoofSurfaceMaterial(node: RoofNode): RoofSurfaceMaterialSpec { + return { + material: node.material, + materialPreset: node.materialPreset, + } +} + +export function getEffectiveRoofSurfaceMaterial( + node: RoofNode, + role: RoofSurfaceMaterialRole, +): RoofSurfaceMaterialSpec { + if (role === 'top') { + if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') { + return { + material: node.topMaterial, + materialPreset: + typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined, + } + } + } + + if (role === 'edge') { + if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { + return { + material: node.edgeMaterial, + materialPreset: + typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined, + } + } + } + + if (role === 'wall') { + if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { + return { + material: node.wallMaterial, + materialPreset: + typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined, + } + } + } + + // No cross-role fallback: an unset role resolves only to the legacy + // catch-all (which covers all three roles for back-compat) and otherwise + // to the caller's theme default. Painting one surface must never bleed + // onto the others. + return getLegacyRoofSurfaceMaterial(node) +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/scan.ts b/services/pascal-editor/packages/core/src/schema/nodes/scan.ts new file mode 100644 index 000000000..9f50a1b5e --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/scan.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { AssetUrl } from '../asset-url' +import { BaseNode, nodeType, objectId } from '../base' + +export const ScanNode = BaseNode.extend({ + id: objectId('scan'), + type: nodeType('scan'), + url: AssetUrl, + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + scale: z.number().default(1), + opacity: z.number().min(0).max(100).default(100), +}) + +export type ScanNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/shelf.ts b/services/pascal-editor/packages/core/src/schema/nodes/shelf.ts new file mode 100644 index 000000000..3e9b74b2a --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/shelf.ts @@ -0,0 +1,98 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' +import { ItemNode } from './item' + +/** + * Parametric shelf — a configurable furniture unit with one or more + * horizontal boards that host other items. + * + * Four styles share the same dimensional schema: + * + * - `wall-shelf` — open boards held by end brackets. `rows > 1` stacks + * evenly-spaced boards. Brackets style: `minimal | industrial | hidden`. + * The v1 archetype. + * - `bookshelf` — full-height cabinet: side panels + multiple shelf + * boards. `columns > 1` adds vertical dividers between sections. + * `withBack` toggles a back panel. `withSides` toggles the side + * panels (`false` = open silhouette held by cross-brace posts). + * - `open-rack` — industrial wire-rack style: four corner posts, no + * side panels, slim boards. `withBack` adds an X-brace. + * - `cubby` — grid of pigeonhole cubicles: `rows × columns` cells + * formed by full back + sides + inner dividers. Each cubicle hosts + * items on its own bottom surface. + * + * `height` is the distance from floor to the underside of the topmost + * board (legacy v1 semantic, preserved so v1 scenes load with identical + * top-board placement). For `rows > 1`, boards are evenly spaced from + * `height / rows` up to `height`. For `cubby`, the height divides into + * `rows` equal-height cubicles. + * + * Items host on each row's top surface via `capabilities.surfaces.custom`. + */ +export const ShelfNode = BaseNode.extend({ + id: objectId('shelf'), + type: nodeType('shelf'), + // Hosted items live here — without this field `createNode(item, shelf)` + // would write `item.parentId = shelf.id` but skip the children-list + // update, so the shelf renderer wouldn't pick the item up and React + // would never mount it (the item would exist in `useScene.nodes` but + // not be rendered, making the commit look like "the item went + // somewhere else"). The action's parent-update branch needs the field + // present at parse-time so the children array is always defined. + children: z.array(ItemNode.shape.id).default([]), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + + // Dimensions (meters). Schema-level defaults intentionally reproduce + // the v1 wall-shelf so existing v1 scenes that omit the v2-introduced + // fields (style / rows / columns / with*) load with their original + // visual unchanged. The user-facing "place a fresh shelf" defaults + // (cubby 3x2 @ 1m × 0.5m × 1.8m) live on `shelfDefinition.defaults()` + // and are applied by the placement tool, NOT here. + width: z.number().min(0.3).max(3.0).default(1.2), + depth: z.number().min(0.1).max(1.0).default(0.3), + /** Board thickness — shared by top boards, sides, back, dividers. */ + thickness: z.number().min(0.01).max(0.1).default(0.04), + /** + * Distance from floor to the underside of the topmost board. For + * `rows > 1`, intermediate boards are evenly spaced from `height/rows` + * up to `height`. + */ + height: z.number().min(0.05).max(2.5).default(0.9), + + // Style + topology — v2 additions, default to v1 visual (single-board + // wall shelf) so v1 scenes are forward-compatible without migration. + style: z.enum(['wall-shelf', 'bookshelf', 'open-rack', 'cubby']).default('wall-shelf'), + rows: z.number().int().min(1).max(8).default(1), + columns: z.number().int().min(1).max(6).default(1), + withBack: z.boolean().default(false), + withSides: z.boolean().default(true), + /** + * Renders a horizontal board at floor level — closes the bottom row of + * a cubby (or the base of a bookshelf) so items can host on a real + * surface rather than the open floor. No-op for `wall-shelf` / + * `open-rack` where the structure has no enclosed bottom cell. + */ + withBottom: z.boolean().default(false), + + bracketStyle: z.enum(['minimal', 'industrial', 'hidden']).default('minimal'), + + // Paintable surface — same shape walls / slabs / stairs use. The default + // is unset (renders as the off-white `DEFAULT_SHELF_MATERIAL`); paint + // mode writes the chosen catalog material here. Keeping the same field + // names (`material` / `materialPreset`) lets the existing + // `buildSurfaceMaterialPatch` helpers in `material-paint.ts` work + // unchanged once `'shelf'` is added to `MaterialTarget`. + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + + // Per-slot material overrides, mirroring `ItemNode.slots`. Key = slot id + // (`shelves` / `frame` / `back`, see `shelfSlots`), value = a `MaterialRef` + // string (`library:` or `scene:`). Absent slot = fall back to the + // legacy whole-shelf `material` / `materialPreset`, then the registry slot + // default. A dangling ref renders the default (never blocks). + slots: z.record(z.string(), z.string()).optional(), +}) + +export type ShelfNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/site.ts b/services/pascal-editor/packages/core/src/schema/nodes/site.ts new file mode 100644 index 000000000..6cb33d681 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/site.ts @@ -0,0 +1,43 @@ +// lib/scenegraph/schema/nodes/site.ts + +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +// 2D Polygon +const PropertyLineData = z.object({ + type: z.literal('polygon'), + points: z.array(z.tuple([z.number(), z.number()])), +}) + +// 3D Polygon/Mesh +// const TerrainData = z.object({ +// type: z.literal('terrain'), +// points: z.array(z.tuple([z.number(), z.number(), z.number()])), +// }) + +export const SiteNode = BaseNode.extend({ + id: objectId('site'), + type: nodeType('site'), + // Specific props + polygon: PropertyLineData.optional().default({ + type: 'polygon', + // Default 30x30 square centered at origin + points: [ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ], + }), + // terrain: TerrainData, + children: z.array(z.string()).default([]), +}).describe( + dedent` + Site node - used to represent a site + - polygon: polygon data + - children: array of child node ids (buildings, items) + `, +) + +export type SiteNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/skylight.ts b/services/pascal-editor/packages/core/src/schema/nodes/skylight.ts new file mode 100644 index 000000000..8de08becc --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/skylight.ts @@ -0,0 +1,200 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const SkylightMaterialRole = z.enum(['frame', 'glass']) +export type SkylightMaterialRole = z.infer + +export const SKYLIGHT_TYPE_ORDER = ['flat', 'walk-on', 'lantern', 'opening', 'sliding'] as const + +export const SkylightType = z.enum(SKYLIGHT_TYPE_ORDER) +export type SkylightType = (typeof SKYLIGHT_TYPE_ORDER)[number] + +export const SkylightOpeningSide = z.enum(['top', 'bottom', 'left', 'right']) +export type SkylightOpeningSide = z.infer + +export const SkylightSlideDirection = z.enum(['x', 'z']) +export type SkylightSlideDirection = z.infer + +export type SkylightTypePreset = { + label: string + width: number + height: number + frameThickness: number + frameDepth: number + glassThickness: number + curb: boolean + curbHeight: number + cutoutOffset: number + lanternHeight: number + lanternTopScale: number + openingAngle: number + openingSide: SkylightOpeningSide + operationState: number + motorHousing: boolean + slideFraction: number + slideDirection: SkylightSlideDirection + trackWidth: number + motorHousingSize: number +} + +export const SKYLIGHT_TYPE_PRESETS = { + flat: { + label: 'Flat roof skylight', + width: 0.9, + height: 1.2, + frameThickness: 0.055, + frameDepth: 0.08, + glassThickness: 0.012, + curb: true, + curbHeight: 0.08, + cutoutOffset: 0, + lanternHeight: 0.25, + lanternTopScale: 0, + openingAngle: 0, + openingSide: 'top', + operationState: 0, + motorHousing: false, + slideFraction: 0, + slideDirection: 'z', + trackWidth: 0.04, + motorHousingSize: 0.08, + }, + 'walk-on': { + label: 'Walk-on rooflight', + width: 1.2, + height: 1.8, + frameThickness: 0.035, + frameDepth: 0.045, + glassThickness: 0.04, + curb: false, + curbHeight: 0, + cutoutOffset: 0, + lanternHeight: 0.25, + lanternTopScale: 0, + openingAngle: 0, + openingSide: 'top', + operationState: 0, + motorHousing: false, + slideFraction: 0, + slideDirection: 'z', + trackWidth: 0.04, + motorHousingSize: 0.08, + }, + lantern: { + label: 'Roof lantern', + width: 1.4, + height: 1.4, + frameThickness: 0.06, + frameDepth: 0.08, + glassThickness: 0.012, + curb: true, + curbHeight: 0.16, + cutoutOffset: 0, + lanternHeight: 0.45, + lanternTopScale: 0, + openingAngle: 0, + openingSide: 'top', + operationState: 0, + motorHousing: false, + slideFraction: 0, + slideDirection: 'z', + trackWidth: 0.04, + motorHousingSize: 0.08, + }, + opening: { + label: 'Opening skylight', + width: 0.9, + height: 1.2, + frameThickness: 0.06, + frameDepth: 0.035, + glassThickness: 0.014, + curb: true, + curbHeight: 0.1, + cutoutOffset: 0, + lanternHeight: 0.25, + lanternTopScale: 0, + openingAngle: Math.PI / 8, + openingSide: 'top', + operationState: 1, + motorHousing: false, + slideFraction: 0, + slideDirection: 'z', + trackWidth: 0.04, + motorHousingSize: 0.08, + }, + sliding: { + label: 'Sliding skylight', + width: 1.4, + height: 1.1, + frameThickness: 0.055, + frameDepth: 0.08, + glassThickness: 0.012, + curb: true, + curbHeight: 0.08, + cutoutOffset: 0, + lanternHeight: 0.25, + lanternTopScale: 0, + openingAngle: 0, + openingSide: 'top', + operationState: 0.35, + motorHousing: false, + slideFraction: 0.35, + slideDirection: 'x', + trackWidth: 0.045, + motorHousingSize: 0.08, + }, +} as const satisfies Record + +const DEFAULT = SKYLIGHT_TYPE_PRESETS.flat + +export const SkylightNode = BaseNode.extend({ + id: objectId('skylight'), + type: nodeType('skylight'), + + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + glassMaterial: MaterialSchema.optional(), + glassMaterialPreset: z.string().optional(), + + roofSegmentId: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + width: z.number().default(DEFAULT.width), + height: z.number().default(DEFAULT.height), + frameThickness: z.number().default(DEFAULT.frameThickness), + frameDepth: z.number().default(DEFAULT.frameDepth), + + skylightType: SkylightType.default('flat'), + + glassThickness: z.number().default(DEFAULT.glassThickness), + + lanternHeight: z.number().default(DEFAULT.lanternHeight), + lanternTopScale: z.number().default(DEFAULT.lanternTopScale), + + openingAngle: z.number().default(DEFAULT.openingAngle), + openingSide: SkylightOpeningSide.default(DEFAULT.openingSide), + operationState: z.number().min(0).max(1).default(DEFAULT.operationState), + motorHousing: z.boolean().default(DEFAULT.motorHousing), + + slideFraction: z.number().min(0).max(1).default(DEFAULT.slideFraction), + slideDirection: SkylightSlideDirection.default(DEFAULT.slideDirection), + trackWidth: z.number().default(DEFAULT.trackWidth), + motorHousingSize: z.number().default(DEFAULT.motorHousingSize), + + curb: z.boolean().default(DEFAULT.curb), + curbHeight: z.number().default(DEFAULT.curbHeight), + cutoutOffset: z.number().default(DEFAULT.cutoutOffset), + + surfaceNormal: z.tuple([z.number(), z.number(), z.number()]).optional(), +}).describe( + dedent` + Skylight — a framed glass opening hosted on a roof segment. Five + type variants drive different geometry (flat / walk-on / lantern / + opening / sliding) and animation states. + `, +) + +export type SkylightNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/slab.ts b/services/pascal-editor/packages/core/src/schema/nodes/slab.ts new file mode 100644 index 000000000..fe3c47c1d --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/slab.ts @@ -0,0 +1,32 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' +import { SurfaceHoleMetadata } from './surface-hole-metadata' + +export const SlabNode = BaseNode.extend({ + id: objectId('slab'), + type: nodeType('slab'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef` + // (`library:` / `scene:`). Absent = the declared slot default. + slots: z.record(z.string(), z.string()).optional(), + polygon: z.array(z.tuple([z.number(), z.number()])), + holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), + holeMetadata: z.array(SurfaceHoleMetadata).default([]), + elevation: z.number().default(0.05), // Elevation in meters + autoFromWalls: z.boolean().default(false), +}).describe( + dedent` + Slab node - used to represent a slab/floor in the building + - polygon: array of [x, z] points defining the slab boundary + - holes: array of [x, z] polygons representing cutouts in the slab + - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts + - elevation: elevation in meters + - autoFromWalls: whether the slab is automatically generated from a closed wall loop + `, +) + +export type SlabNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/solar-panel.ts b/services/pascal-editor/packages/core/src/schema/nodes/solar-panel.ts new file mode 100644 index 000000000..2d81e4dd4 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/solar-panel.ts @@ -0,0 +1,63 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { SolarPanelPresetKey } from '../../solar-panel-presets' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const SolarPanelMaterialRole = z.enum(['frame', 'panel']) +export type SolarPanelMaterialRole = z.infer + +export const SolarPanelNode = BaseNode.extend({ + id: objectId('solarpanel'), + type: nodeType('solar-panel'), + + // Frame / rail material — aluminum mounting rails. + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + // Panel surface material — dark photovoltaic glass. + panelMaterial: MaterialSchema.optional(), + panelMaterialPreset: z.string().optional(), + + // Visual preset that drove panelWidth / panelHeight / frameThickness / + // frameDepth. Cleared whenever any of those four is edited manually. + panelTypePreset: SolarPanelPresetKey.optional(), + + roofSegmentId: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + // Grid layout. + rows: z.number().int().min(1).max(20).default(2), + columns: z.number().int().min(1).max(20).default(3), + + // Individual panel dimensions (meters). Defaults match the 'residential' preset. + panelWidth: z.number().default(1.0), + panelHeight: z.number().default(1.65), + + // Gaps between panels. + gapX: z.number().default(0.02), + gapY: z.number().default(0.02), + + // Mounting type — flush (flat on the slope) or tilted (angled up). + mountingType: z.enum(['flush', 'tilted']).default('flush'), + tiltAngle: z.number().default(15), + standoffHeight: z.number().default(0.05), + + // Frame. + frameThickness: z.number().default(0.04), + frameDepth: z.number().default(0.04), + + // Surface normal at placement point (segment-local). Captured from a + // raycast on the shingle surface during placement / move. Falls back + // to the analytical normal in the renderer if absent. + surfaceNormal: z.tuple([z.number(), z.number(), z.number()]).optional(), +}).describe( + dedent` + Solar panel array — a grid of photovoltaic panels mounted on a roof + segment. Position is segment-local (x along width, z along depth); + the Y component is captured from the raycast hit on the shingle + surface, with an analytical fallback in the renderer when it's 0. + `, +) + +export type SolarPanelNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/spawn.ts b/services/pascal-editor/packages/core/src/schema/nodes/spawn.ts new file mode 100644 index 000000000..521d3f810 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/spawn.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const SpawnNode = BaseNode.extend({ + id: objectId('spawn'), + type: nodeType('spawn'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), +}) + +export type SpawnNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/stair-segment.ts b/services/pascal-editor/packages/core/src/schema/nodes/stair-segment.ts new file mode 100644 index 000000000..749675a73 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/stair-segment.ts @@ -0,0 +1,54 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const StairSegmentType = z.enum(['stair', 'landing']) + +export type StairSegmentType = z.infer + +export const AttachmentSide = z.enum(['front', 'left', 'right']) + +export type AttachmentSide = z.infer + +export const StairSegmentNode = BaseNode.extend({ + id: objectId('sseg'), + type: nodeType('stair-segment'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around Y axis in radians + rotation: z.number().default(0), + // Stair or landing + segmentType: StairSegmentType.default('stair'), + // Width of the stair flight / landing + width: z.number().default(1.0), + // Horizontal run (depth along travel direction) + length: z.number().default(3.0), + // Vertical rise (0 for landings) + height: z.number().default(2.5), + // Number of steps (only used for stair type) + stepCount: z.number().default(10), + // Which side of the previous segment to attach to + attachmentSide: AttachmentSide.default('front'), + // Whether to fill the underside down to floor level + fillToFloor: z.boolean().default(true), + // Thickness of the stair slab when not filled to floor + thickness: z.number().default(0.25), +}).describe( + dedent` + Stair segment node - an individual flight or landing within a stair group. + Each segment generates a complete stair/landing geometry. + Multiple segments chain together to form complex staircase shapes (L-shape, U-shape, etc.). + - segmentType: stair (with steps) or landing (flat platform) + - width: width of the flight/landing + - length: horizontal run distance + - height: vertical rise (0 for landings) + - stepCount: number of steps (stair type only) + - attachmentSide: front, left, or right - which side of the previous segment to attach to + - fillToFloor: whether to fill the underside down to the absolute floor level + - thickness: slab thickness when not filled to floor + `, +) + +export type StairSegmentNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/stair.ts b/services/pascal-editor/packages/core/src/schema/nodes/stair.ts new file mode 100644 index 000000000..abc1fe54a --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/stair.ts @@ -0,0 +1,167 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import type { MaterialSchema as MaterialSchemaType } from '../material' +import { MaterialSchema } from '../material' +import { StairSegmentNode } from './stair-segment' + +export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) +export const StairType = z.enum(['straight', 'curved', 'spiral']) +export const StairTopLandingMode = z.enum(['none', 'integrated']) +export const StairSlabOpeningMode = z.enum(['none', 'destination']) + +export type StairRailingMode = z.infer +export type StairType = z.infer +export type StairTopLandingMode = z.infer +export type StairSlabOpeningMode = z.infer +export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side' +export type StairSurfaceMaterialSpec = { + material?: MaterialSchemaType + materialPreset?: string +} + +export const StairNode = BaseNode.extend({ + id: objectId('stair'), + type: nodeType('stair'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + railingMaterial: MaterialSchema.optional(), + railingMaterialPreset: z.string().optional(), + treadMaterial: MaterialSchema.optional(), + treadMaterialPreset: z.string().optional(), + sideMaterial: MaterialSchema.optional(), + sideMaterialPreset: z.string().optional(), + // Unified paint-slot refs (`scene:`/`library:` MaterialRef per slot id), + // matching the slot model items/slab/shelf use. Absent = declared default. + slots: z.record(z.string(), z.string()).optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around Y axis in radians + rotation: z.number().default(0), + stairType: StairType.default('straight'), + fromLevelId: z.string().nullable().default(null), + toLevelId: z.string().nullable().default(null), + slabOpeningMode: StairSlabOpeningMode.default('none'), + openingOffset: z.number().default(0), + width: z.number().default(1.0), + totalRise: z.number().default(2.5), + stepCount: z.number().default(10), + thickness: z.number().default(0.25), + fillToFloor: z.boolean().default(true), + innerRadius: z.number().default(0.9), + sweepAngle: z.number().default(Math.PI / 2), + topLandingMode: StairTopLandingMode.default('none'), + topLandingDepth: z.number().default(0.9), + showCenterColumn: z.boolean().default(true), + showStepSupports: z.boolean().default(true), + railingMode: StairRailingMode.default('none'), + railingHeight: z.number().default(0.92), + // Child stair segment IDs + children: z.array(StairSegmentNode.shape.id).default([]), +}).describe( + dedent` + Stair node - a container for stair segments. + Acts as a group that either holds one or more StairSegmentNodes (straight stairs) + or stores stair-level geometry properties for curved stairs. + - position: center position of the stair group + - rotation: rotation around Y axis + - stairType: straight (segment-based), curved (arc-based), or spiral + - fromLevelId / toLevelId: source and destination levels used for auto slab cutouts + - slabOpeningMode: whether a destination-level slab opening is generated for this stair + - openingOffset: extra opening expansion applied after the cutout polygon is computed + - width: stair width + - totalRise: total stair height + - stepCount: number of visible steps + - 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 + - sweepAngle: total curved stair sweep in radians + - topLandingMode: optional integrated top landing for spiral stairs + - topLandingDepth: depth used to size the integrated spiral top landing + - showCenterColumn: whether spiral stairs render a center column + - showStepSupports: whether spiral stairs render step support brackets + - railingMode: whether to render railings and on which side(s) + - railingHeight: top height of the railing above the stair surface + - children: array of StairSegmentNode IDs for straight stairs + `, +) + +export type StairNode = z.infer + +function getLegacyStairSurfaceMaterial(node: StairNode): StairSurfaceMaterialSpec { + return { + material: node.material, + materialPreset: node.materialPreset, + } +} + +export function getEffectiveStairSurfaceMaterial( + node: StairNode, + role: StairSurfaceMaterialRole, +): StairSurfaceMaterialSpec { + if (role === 'railing') { + if (node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string') { + return { + material: node.railingMaterial, + materialPreset: + typeof node.railingMaterialPreset === 'string' ? node.railingMaterialPreset : undefined, + } + } + } + + if (role === 'tread') { + if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') { + return { + material: node.treadMaterial, + materialPreset: + typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + } + } + } + + if (role === 'side') { + if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') { + return { + material: node.sideMaterial, + materialPreset: + typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + } + } + } + + const treadFallback = { + material: node.treadMaterial, + materialPreset: + typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + } + const sideFallback = { + material: node.sideMaterial, + materialPreset: + typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + } + + if ( + role === 'tread' && + (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined) + ) { + return sideFallback + } + + if ( + role === 'side' && + (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined) + ) { + return treadFallback + } + + if (role === 'railing') { + if (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined) { + return treadFallback + } + + if (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined) { + return sideFallback + } + } + + return getLegacyStairSurfaceMaterial(node) +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/surface-hole-metadata.ts b/services/pascal-editor/packages/core/src/schema/nodes/surface-hole-metadata.ts new file mode 100644 index 000000000..a3d560e83 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/surface-hole-metadata.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const SurfaceHoleMetadata = z.object({ + // Stair/elevator auto-openings use stairId/elevatorId so sync can replace only its own holes. + source: z.enum(['manual', 'stair', 'elevator']).default('manual'), + stairId: z.string().optional(), + elevatorId: z.string().optional(), +}) + +export type SurfaceHoleMetadata = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/turbine-vent.ts b/services/pascal-editor/packages/core/src/schema/nodes/turbine-vent.ts new file mode 100644 index 000000000..d828fe4cd --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/turbine-vent.ts @@ -0,0 +1,50 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const TurbineVentNode = BaseNode.extend({ + id: objectId('tvent'), + type: nodeType('turbine-vent'), + + material: MaterialSchema.optional(), + // Default to the white preset so a freshly-placed turbine reads as + // clean painted/galvanised metal and the paint inspector shows "White" + // as the current selection (matches box-vent's reasoning). + materialPreset: z.string().default('preset-white'), + + roofSegmentId: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + // Overall diameter of the spinning head at its widest point. + diameter: z.number().default(0.32), + // Total height from the flange base to the top knob. + height: z.number().default(0.42), + // Height of the throat cylinder between the flange and the head. The + // head occupies the remaining `height - neckHeight`. + neckHeight: z.number().default(0.09), + // How far the flange flashing flares past the throat on every side. + baseOverhang: z.number().default(0.05), + // Number of curved vanes around the head. More vanes read denser / + // finer; fewer read as a chunkier industrial turbine. + vaneCount: z.number().int().default(20), + // Idle spin speed in radians/second. 0 = static (the head holds still), + // which is the default — a newly-placed turbine starts paused and is set + // spinning via the panel's Play toggle or the Spin Speed slider. Driven by + // the renderer's `useFrame`, not stored per-frame. + spinSpeed: z.number().default(0), + + // `globe` → classic spherical whirlybird (bulged barrel + domed top) + // `cylinder`→ straight-barrel turbine with a flat disc top + style: z.enum(['globe', 'cylinder']).default('globe'), +}).describe( + dedent` + Turbine vent (whirlybird) — a wind-driven spinning exhaust vent that + sits on a roof slope. A finned head spins on a throat above a flange. + Parented to a roof-segment; position is segment-local. Rotation is + around the segment's vertical axis (post-slope tilt). + `, +) + +export type TurbineVentNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/wall.ts b/services/pascal-editor/packages/core/src/schema/nodes/wall.ts new file mode 100644 index 000000000..a93d22bd7 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/wall.ts @@ -0,0 +1,121 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' +import { DoorNode } from './door' +import { ItemNode } from './item' +import { WindowNode } from './window' + +export const WallNode = BaseNode.extend({ + id: objectId('wall'), + type: nodeType('wall'), + children: z + .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) + .default([]), + // Legacy single-material wall finish. Read for backward compatibility only. + material: MaterialSchema.optional(), + // Legacy single-material wall finish preset. Read for backward compatibility only. + materialPreset: z.string().optional(), + interiorMaterial: MaterialSchema.optional(), + interiorMaterialPreset: z.string().optional(), + exteriorMaterial: MaterialSchema.optional(), + exteriorMaterialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `SlabNode.slots`. Key = slot id (`interior` / `exterior`), value = a + // `MaterialRef` (`library:` / `scene:`). Absent = the declared slot + // default (`WALL_SLOT_DEFAULT`). The legacy `*Material*` fields above are + // read only by the load migration that moves them into `slots`; delete them + // in a follow-up once migrated scenes are the norm. + slots: z.record(z.string(), z.string()).optional(), + thickness: z.number().optional(), + height: z.number().optional(), + curveOffset: z.number().optional(), + // e.g., start/end points for path + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + // Space detection for cutaway mode + frontSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'), + backSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'), +}).describe( + dedent` + Wall node - used to represent a wall in the building + - thickness: thickness in meters + - height: height in meters + - curveOffset: midpoint sagitta offset used to bend the wall into an arc + - start: start point of the wall in level coordinate system + - end: end point of the wall in level coordinate system + - size: size of the wall in grid units + - frontSide: whether the front side faces interior, exterior, or unknown + - backSide: whether the back side faces interior, exterior, or unknown + `, +) +export type WallNode = z.infer + +export type WallSurfaceSide = 'interior' | 'exterior' + +// Declared default appearance for an unpainted wall face in colored mode — +// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the +// slot declaration (nodes) and the material resolver (viewer) share one value. +// May be a `#rrggbb` colour or a `library:` ref. Textures-off still +// collapses to the themed wall role (the escape hatch). +export const WALL_SLOT_DEFAULT: Record = { + interior: 'library:concrete-drywall', + exterior: 'library:concrete-drywall', +} + +export type WallSurfaceMaterialSpec = { + material?: z.infer + materialPreset?: string +} + +type WallSurfaceMaterialSource = { + material?: z.infer + materialPreset?: string + interiorMaterial?: z.infer + interiorMaterialPreset?: string + exteriorMaterial?: z.infer + exteriorMaterialPreset?: string +} + +function getConfiguredWallSurfaceMaterial( + wall: WallSurfaceMaterialSource, + side: WallSurfaceSide, +): WallSurfaceMaterialSpec { + if (side === 'interior') { + return { + material: wall.interiorMaterial, + materialPreset: wall.interiorMaterialPreset, + } + } + + return { + material: wall.exteriorMaterial, + materialPreset: wall.exteriorMaterialPreset, + } +} + +function hasSurfaceMaterial(spec: WallSurfaceMaterialSpec): boolean { + return spec.material !== undefined || typeof spec.materialPreset === 'string' +} + +export function getEffectiveWallSurfaceMaterial( + wall: WallSurfaceMaterialSource, + side: WallSurfaceSide, +): WallSurfaceMaterialSpec { + const configured = getConfiguredWallSurfaceMaterial(wall, side) + if (hasSurfaceMaterial(configured)) { + return configured + } + + return { + material: wall.material, + materialPreset: wall.materialPreset, + } +} + +export function getWallSurfaceMaterialSignature(spec: WallSurfaceMaterialSpec): string { + return JSON.stringify({ + material: spec.material ?? null, + materialPreset: spec.materialPreset ?? null, + }) +} diff --git a/services/pascal-editor/packages/core/src/schema/nodes/window.ts b/services/pascal-editor/packages/core/src/schema/nodes/window.ts new file mode 100644 index 000000000..08c5ce4ef --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/window.ts @@ -0,0 +1,93 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const WindowType = z.enum([ + 'fixed', + 'sliding', + 'casement', + 'awning', + 'hopper', + 'single-hung', + 'double-hung', + 'bay', + 'bow', + 'louvered', +]) +export type WindowType = z.infer + +export const WindowNode = BaseNode.extend({ + id: objectId('window'), + type: nodeType('window'), + material: MaterialSchema.optional(), + // Per-slot material overrides on the unified slot model. Keys: `frame`, + // `glass`. Value = a `MaterialRef` (`library:` / `scene:`). Absent = + // the frame/glass default. Mirrors `ShelfNode.slots`. + slots: z.record(z.string(), z.string()).optional(), + + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + side: z.enum(['front', 'back']).optional(), + + // Wall reference + wallId: z.string().optional(), + // Alternative host: a roof-segment's generated wall face (base wall + // under the roof or a coplanar gable end). When set, `position` is + // FACE-LOCAL — [u along the face, v height, z from the wall mid-plane] + // — exactly the wall-child convention; the renderer mounts the node + // inside the face frame (`getRoofWallFaceFrame`), which is what makes + // hosted children track segment resizes live. + roofSegmentId: z.string().optional(), + roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), + + // Overall dimensions + width: z.number().default(1.5), + height: z.number().default(1.5), + + // Opening mode - when set to "opening", the window is only a shaped cutout + openingKind: z.enum(['window', 'opening']).default('window'), + + // Window family + windowType: WindowType.default('fixed'), + operationState: z.number().min(0).max(1).default(0), + awningDirection: z.enum(['up', 'down']).default('up'), + casementStyle: z.enum(['single', 'french']).default('single'), + hingesSide: z.enum(['left', 'right']).default('left'), + openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), + openingRadiusMode: z.enum(['all', 'individual']).default('all'), + openingCornerRadii: z + .tuple([z.number(), z.number(), z.number(), z.number()]) + .default([0.15, 0.15, 0.15, 0.15]), + cornerRadius: z.number().default(0.15), + archHeight: z.number().default(0.35), + openingRevealRadius: z.number().default(0.025), + + // Frame + frameThickness: z.number().default(0.05), + frameDepth: z.number().default(0.07), + + // Divisions — ratios allow non-uniform panes + // [0.5, 0.5] = two equal panes + // [0.6, 0.4] = one larger, one smaller + // [1] = single pane (no division) + columnRatios: z.array(z.number()).default([1]), + rowRatios: z.array(z.number()).default([1]), + columnDividerThickness: z.number().default(0.03), + rowDividerThickness: z.number().default(0.03), + + // Sill + sill: z.boolean().default(true), + sillDepth: z.number().default(0.08), + sillThickness: z.number().default(0.03), +}).describe(dedent`Window node - a parametric window placed on a wall + - position: center of the window in wall-local coordinate system + - width/height: overall outer dimensions + - windowType: explicit window family, defaulting old windows to fixed + - frameThickness: width of the frame members + - frameDepth: how deep the frame sits within the wall + - columnRatios/rowRatios: pane division ratios + - sill: whether to show a window sill +`) + +export type WindowNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/nodes/zone.ts b/services/pascal-editor/packages/core/src/schema/nodes/zone.ts new file mode 100644 index 000000000..429d33489 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/nodes/zone.ts @@ -0,0 +1,27 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const ZoneNode = BaseNode.extend({ + id: objectId('zone'), + type: nodeType('zone'), + name: z.string(), + // Polygon boundary - array of [x, z] coordinates defining the zone + polygon: z.array(z.tuple([z.number(), z.number()])), + // Visual styling + color: z.string().default('#3b82f6'), // Default blue + metadata: z.json().optional().default({}), +}).describe( + dedent` + Zone schema - a polygon zone attached to a level + - object: "zone" + - id: zone id + - levelId: level this zone is attached to + - name: zone name + - polygon: array of [x, z] points defining the zone boundary + - color: hex color for visual styling + - metadata: zone metadata (optional) + `, +) + +export type ZoneNode = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/scene-material.ts b/services/pascal-editor/packages/core/src/schema/scene-material.ts new file mode 100644 index 000000000..4b1bbd3f6 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/scene-material.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' +import { generateId } from './base' +import { MaterialSchema } from './material' + +export type SceneMaterialId = `mat_${string}` +export const generateSceneMaterialId = (): SceneMaterialId => generateId('mat') + +export const SceneMaterial = z.object({ + id: z.string(), + name: z.string(), + material: MaterialSchema, +}) +export type SceneMaterial = z.infer diff --git a/services/pascal-editor/packages/core/src/schema/types.ts b/services/pascal-editor/packages/core/src/schema/types.ts new file mode 100644 index 000000000..d404367b0 --- /dev/null +++ b/services/pascal-editor/packages/core/src/schema/types.ts @@ -0,0 +1,90 @@ +import z from 'zod' +import { BoxVentNode } from './nodes/box-vent' +import { BuildingNode } from './nodes/building' +import { CeilingNode } from './nodes/ceiling' +import { ChimneyNode } from './nodes/chimney' +import { ColumnNode } from './nodes/column' +import { CupolaNode } from './nodes/cupola' +import { DoorNode } from './nodes/door' +import { DormerNode } from './nodes/dormer' +import { DownspoutNode } from './nodes/downspout' +import { DuctFittingNode } from './nodes/duct-fitting' +import { DuctSegmentNode } from './nodes/duct-segment' +import { DuctTerminalNode } from './nodes/duct-terminal' +import { ElevatorNode } from './nodes/elevator' +import { EyebrowVentNode } from './nodes/eyebrow-vent' +import { FenceNode } from './nodes/fence' +import { GuideNode } from './nodes/guide' +import { GutterNode } from './nodes/gutter' +import { HvacEquipmentNode } from './nodes/hvac-equipment' +import { ItemNode } from './nodes/item' +import { LevelNode } from './nodes/level' +import { LinesetNode } from './nodes/lineset' +import { LiquidLineNode } from './nodes/liquid-line' +import { PipeFittingNode } from './nodes/pipe-fitting' +import { PipeSegmentNode } from './nodes/pipe-segment' +import { PipeTrapNode } from './nodes/pipe-trap' +import { RidgeVentNode } from './nodes/ridge-vent' +import { RoofNode } from './nodes/roof' +import { RoofSegmentNode } from './nodes/roof-segment' +import { ScanNode } from './nodes/scan' +import { ShelfNode } from './nodes/shelf' +import { SiteNode } from './nodes/site' +import { SkylightNode } from './nodes/skylight' +import { SlabNode } from './nodes/slab' +import { SolarPanelNode } from './nodes/solar-panel' +import { SpawnNode } from './nodes/spawn' +import { StairNode } from './nodes/stair' +import { StairSegmentNode } from './nodes/stair-segment' +import { TurbineVentNode } from './nodes/turbine-vent' +import { WallNode } from './nodes/wall' +import { WindowNode } from './nodes/window' +import { ZoneNode } from './nodes/zone' + +export const AnyNode = z.discriminatedUnion('type', [ + SiteNode, + BuildingNode, + ElevatorNode, + LevelNode, + ColumnNode, + WallNode, + FenceNode, + ItemNode, + ZoneNode, + SlabNode, + CeilingNode, + RoofNode, + RoofSegmentNode, + ShelfNode, + StairNode, + StairSegmentNode, + ScanNode, + GuideNode, + SpawnNode, + WindowNode, + DoorNode, + BoxVentNode, + RidgeVentNode, + TurbineVentNode, + CupolaNode, + EyebrowVentNode, + GutterNode, + ChimneyNode, + SolarPanelNode, + SkylightNode, + DormerNode, + DownspoutNode, + DuctSegmentNode, + DuctFittingNode, + DuctTerminalNode, + HvacEquipmentNode, + LinesetNode, + LiquidLineNode, + PipeSegmentNode, + PipeFittingNode, + PipeTrapNode, +]) + +export type AnyNode = z.infer +export type AnyNodeType = AnyNode['type'] +export type AnyNodeId = AnyNode['id'] diff --git a/services/pascal-editor/packages/core/src/services/alignment-anchors.test.ts b/services/pascal-editor/packages/core/src/services/alignment-anchors.test.ts new file mode 100644 index 000000000..3b9361a73 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/alignment-anchors.test.ts @@ -0,0 +1,449 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../registry' +import type { AnyNodeDefinition } from '../registry/types' +import type { AnyNode } from '../schema/types' +import { + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, +} from '../systems/elevator/elevator-geometry' +import { stairFootprintAABB } from '../systems/stair/stair-footprint' +import { + collectAlignmentAnchors, + footprintAABB, + footprintAABBFrom, + movingAlignmentAnchors, + movingFootprintAnchors, + polygonAnchors, + wallSegmentAnchors, +} from './alignment-anchors' + +// Minimal floor-placed def whose footprint reads `dimensions` / `rotation` +// straight off the node, so tests can drive the AABB math directly. +function floorPlacedDef(kind: string, applies?: (n: AnyNode) => boolean): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as any, + category: 'utility', + defaults: () => ({}) as any, + capabilities: { + floorPlaced: { + footprint: (n: AnyNode) => ({ + dimensions: (n as { dimensions?: [number, number, number] }).dimensions ?? [1, 1, 1], + rotation: (n as { rotation?: [number, number, number] }).rotation ?? [0, 0, 0], + }), + ...(applies ? { applies } : {}), + }, + }, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition +} + +// Mirrors the real elevator/stair definitions, which expose their plan +// footprint via the `alignmentFootprint` capability rather than a hardcoded +// branch in the anchor bridge. The glue (shaft-outset box / stair AABB) is +// reproduced here from the same core helpers production uses. +function elevatorDef(): AnyNodeDefinition { + return { + kind: 'elevator', + schemaVersion: 1, + schema: z.object({ type: z.literal('elevator') }) as any, + category: 'structure', + defaults: () => ({}) as any, + capabilities: { + alignmentFootprint: (n: AnyNode) => { + const e = n as any + const wall = getElevatorShaftWallThickness(e) + return { + shape: 'box', + dimensions: [getElevatorShaftWidth(e) + wall * 2, 1, getElevatorShaftDepth(e) + wall * 2], + rotation: [0, e.rotation ?? 0, 0], + } + }, + }, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition +} + +function stairDef(): AnyNodeDefinition { + return { + kind: 'stair', + schemaVersion: 1, + schema: z.object({ type: z.literal('stair') }) as any, + category: 'structure', + defaults: () => ({}) as any, + capabilities: { + alignmentFootprint: (n: AnyNode, nodes?: Readonly>) => { + const aabb = stairFootprintAABB(n as any, nodes) + return aabb ? { shape: 'aabb', ...aabb } : null + }, + }, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition +} + +function plainDef(kind: string): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as any, + category: 'utility', + defaults: () => ({}) as any, + capabilities: {}, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition +} + +const node = (over: Record): AnyNode => over as unknown as AnyNode + +describe('footprintAABBFrom', () => { + test('unrotated box is centred at position', () => { + const aabb = footprintAABBFrom([10, 0, 20], [2, 1, 4], 0) + expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 }) + }) + + test('90° rotation swaps width and depth extents', () => { + const aabb = footprintAABBFrom([0, 0, 0], [2, 1, 4], Math.PI / 2) + expect(aabb.minX).toBeCloseTo(-2, 10) + expect(aabb.maxX).toBeCloseTo(2, 10) + expect(aabb.minZ).toBeCloseTo(-1, 10) + expect(aabb.maxZ).toBeCloseTo(1, 10) + }) +}) + +describe('footprintAABB', () => { + beforeEach(() => nodeRegistry._reset()) + + test('reads dimensions + rotation from a floor-placed kind', () => { + registerNode(floorPlacedDef('box')) + const aabb = footprintAABB( + node({ id: 'b1', type: 'box', position: [10, 0, 20], dimensions: [2, 1, 4] }), + ) + expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 }) + }) + + test('returns null for a kind without a footprint', () => { + registerNode(plainDef('wall')) + expect(footprintAABB(node({ id: 'w1', type: 'wall', position: [0, 0, 0] }))).toBeNull() + }) + + test('derives an elevator footprint from its OUTER SHAFT, not the cab', () => { + // Aligns to the visible shaft outline: cab 2×4 + 0.09 m wall each side → + // 2.18 × 4.18, centred at (10, 20). The cab corners alone would sit ~9 cm + // inside the drawn edge (past the 8 cm snap), so a guide never appeared. + // The footprint comes from the elevator's `alignmentFootprint` (box) cap. + registerNode(elevatorDef()) + const aabb = footprintAABB( + node({ id: 'e1', type: 'elevator', position: [10, 0, 20], width: 2, depth: 4, rotation: 0 }), + ) + expect(aabb).toEqual({ minX: 8.91, minZ: 17.91, maxX: 11.09, maxZ: 22.09 }) + }) + + test('returns null when the kind predicate excludes the node', () => { + registerNode(floorPlacedDef('lamp', (n) => !(n as { attached?: boolean }).attached)) + expect( + footprintAABB(node({ id: 'l1', type: 'lamp', position: [0, 0, 0], attached: true })), + ).toBeNull() + expect( + footprintAABB(node({ id: 'l2', type: 'lamp', position: [0, 0, 0], attached: false })), + ).not.toBeNull() + }) +}) + +describe('movingFootprintAnchors', () => { + beforeEach(() => nodeRegistry._reset()) + + test('relocates the footprint corners around the proposed centre (edges only, no centre anchor)', () => { + registerNode(floorPlacedDef('box')) + const anchors = movingFootprintAnchors( + node({ id: 'm', type: 'box', position: [0, 0, 0], dimensions: [2, 1, 4] }), + 10, + 20, + ) + // 2×4 box centred at (10, 20): corners at x∈{9,11}, z∈{18,22}. + expect(anchors).toHaveLength(4) + expect(anchors.every((a) => a.kind === 'corner')).toBe(true) + expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9, 11])) + expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([18, 22])) + }) + + test('rotationY override drives the AABB regardless of node rotation', () => { + registerNode(floorPlacedDef('box')) + const anchors = movingFootprintAnchors( + node({ + id: 'm', + type: 'box', + position: [0, 0, 0], + dimensions: [2, 1, 4], + rotation: [0, 0, 0], + }), + 0, + 0, + Math.PI / 2, + ) + const xs = anchors.map((a) => a.x) + // Rotated 90°, the 2×4 box spans ±2 in X (its depth) rather than ±1. + expect(Math.max(...xs)).toBeCloseTo(2, 10) + expect(Math.min(...xs)).toBeCloseTo(-2, 10) + }) + + test('returns empty for a footprintless kind', () => { + registerNode(plainDef('wall')) + expect( + movingFootprintAnchors(node({ id: 'w', type: 'wall', position: [0, 0, 0] }), 1, 1), + ).toEqual([]) + }) +}) + +describe('movingAlignmentAnchors', () => { + beforeEach(() => nodeRegistry._reset()) + + test('relocates a straight stair by its segment-chain footprint', () => { + registerNode(stairDef()) + const nodes = { + st: node({ + id: 'st', + type: 'stair', + position: [0, 0, 0], + rotation: 0, + stairType: 'straight', + width: 1, + children: ['seg'], + }), + seg: node({ + id: 'seg', + type: 'stair-segment', + parentId: 'st', + width: 1, + length: 3, + height: 2.5, + attachmentSide: 'front', + }), + } + + const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, 0) + expect(anchors).toHaveLength(4) + expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9.5, 10.5])) + expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([20, 23])) + }) + + test('rotation override drives a moving straight stair footprint', () => { + registerNode(stairDef()) + const nodes = { + st: node({ + id: 'st', + type: 'stair', + position: [0, 0, 0], + rotation: 0, + stairType: 'straight', + width: 1, + children: ['seg'], + }), + seg: node({ + id: 'seg', + type: 'stair-segment', + parentId: 'st', + width: 1, + length: 3, + height: 2.5, + attachmentSide: 'front', + }), + } + + const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, Math.PI / 2) + const xs = anchors.map((a) => a.x) + const zs = anchors.map((a) => a.z) + expect(Math.min(...xs)).toBeCloseTo(10, 10) + expect(Math.max(...xs)).toBeCloseTo(13, 10) + expect(Math.min(...zs)).toBeCloseTo(19.5, 10) + expect(Math.max(...zs)).toBeCloseTo(20.5, 10) + }) +}) + +describe('wallSegmentAnchors', () => { + test('returns both endpoints as corners and the chord midpoint as center', () => { + const anchors = wallSegmentAnchors('w', [0, 0], [4, 2]) + expect(anchors).toEqual([ + { nodeId: 'w', kind: 'corner', x: 0, z: 0 }, + { nodeId: 'w', kind: 'corner', x: 4, z: 2 }, + { nodeId: 'w', kind: 'center', x: 2, z: 1 }, + ]) + }) + + test('adds ±thickness/2 face corners on each endpoint when thickness is given', () => { + // Horizontal wall along +X: perpendicular is ±Z, so faces sit at z = ±0.1. + const anchors = wallSegmentAnchors('w', [0, 0], [4, 0], 0.2) + expect(anchors).toEqual([ + { nodeId: 'w', kind: 'corner', x: 0, z: 0 }, + { nodeId: 'w', kind: 'corner', x: 4, z: 0 }, + { nodeId: 'w', kind: 'center', x: 2, z: 0 }, + { nodeId: 'w', kind: 'corner', x: 0, z: 0.1 }, + { nodeId: 'w', kind: 'corner', x: 0, z: -0.1 }, + { nodeId: 'w', kind: 'corner', x: 4, z: 0.1 }, + { nodeId: 'w', kind: 'corner', x: 4, z: -0.1 }, + ]) + }) + + test('skips face corners for zero/degenerate input', () => { + expect(wallSegmentAnchors('w', [0, 0], [4, 0], 0)).toHaveLength(3) + expect(wallSegmentAnchors('w', [1, 1], [1, 1], 0.2)).toHaveLength(3) + }) +}) + +describe('polygonAnchors', () => { + test('returns each vertex as a corner anchor', () => { + expect( + polygonAnchors('s', [ + [0, 0], + [2, 0], + [2, 3], + ]), + ).toEqual([ + { nodeId: 's', kind: 'corner', x: 0, z: 0 }, + { nodeId: 's', kind: 'corner', x: 2, z: 0 }, + { nodeId: 's', kind: 'corner', x: 2, z: 3 }, + ]) + }) +}) + +describe('collectAlignmentAnchors', () => { + beforeEach(() => nodeRegistry._reset()) + + test('unions footprint corners, segment anchors and polygon vertices, excluding the moving node', () => { + registerNode(floorPlacedDef('box')) + const nodes = { + moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }), + box: node({ id: 'box', type: 'box', position: [5, 0, 5], dimensions: [2, 1, 2] }), + wall: node({ id: 'wall', type: 'wall', start: [0, 0], end: [4, 0] }), + slab: node({ + id: 'slab', + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + ], + }), + } + const anchors = collectAlignmentAnchors(nodes, 'moving') + const ids = anchors.map((a) => a.nodeId) + expect(ids).not.toContain('moving') + expect(ids.filter((id) => id === 'box')).toHaveLength(4) // corner anchors + expect(ids.filter((id) => id === 'wall')).toHaveLength(7) // endpoints + midpoint + 4 face corners + expect(ids.filter((id) => id === 'slab')).toHaveLength(3) // polygon vertices + }) + + test('levelId filter keeps only nodes resolving to that level (incl. nested)', () => { + registerNode(floorPlacedDef('box')) + registerNode(elevatorDef()) + const nodes = { + b: node({ id: 'b', type: 'building' }), + L1: node({ id: 'L1', type: 'level', parentId: 'b' }), + L2: node({ id: 'L2', type: 'level', parentId: 'b' }), + moving: node({ id: 'moving', type: 'box', parentId: 'L1', position: [0, 0, 0] }), + sameFloor: node({ id: 'sameFloor', type: 'box', parentId: 'L1', position: [5, 0, 5] }), + // Item resting on `sameFloor` — resolves to L1 through the parent chain. + nested: node({ id: 'nested', type: 'box', parentId: 'sameFloor', position: [5, 0, 5] }), + otherFloor: node({ id: 'otherFloor', type: 'box', parentId: 'L2', position: [5, 0, 5] }), + // Building-scoped (parented to the building, no level ancestor) — spans + // every floor, so it stays in the pool regardless of the active level. + elevator: node({ + id: 'elevator', + type: 'elevator', + parentId: 'b', + position: [9, 0, 9], + width: 1.6, + depth: 1.6, + }), + } + const ids = collectAlignmentAnchors(nodes, 'moving', 'L1').map((a) => a.nodeId) + expect(ids.filter((id) => id === 'sameFloor')).toHaveLength(4) + expect(ids.filter((id) => id === 'nested')).toHaveLength(4) + expect(ids.filter((id) => id === 'elevator')).toHaveLength(4) + expect(ids).not.toContain('otherFloor') + }) + + test('straight stair contributes its segment-chain footprint corners', () => { + registerNode(stairDef()) + const nodes = { + st: node({ + id: 'st', + type: 'stair', + position: [0, 0, 0], + rotation: 0, + stairType: 'straight', + width: 1, + children: ['seg'], + }), + // Single 1×3 flight; origin at the run start, extending +Z by length. + seg: node({ + id: 'seg', + type: 'stair-segment', + parentId: 'st', + width: 1, + length: 3, + height: 2.5, + attachmentSide: 'front', + }), + } + const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'st') + expect(anchors).toHaveLength(4) + expect(anchors.every((a) => a.kind === 'corner')).toBe(true) + expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([-0.5, 0.5])) + expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([0, 3])) + }) + + test('curved stair contributes its sector bounding-box corners', () => { + registerNode(stairDef()) + const nodes = { + cs: node({ + id: 'cs', + type: 'stair', + position: [0, 0, 0], + rotation: 0, + stairType: 'curved', + width: 1, + innerRadius: 1, + sweepAngle: Math.PI / 2, + }), + } + const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'cs') + expect(anchors).toHaveLength(4) + // outerRadius = inner(1) + width(1) = 2, sweep π/2 centred on +X. Outer rim + // reaches X=2 at the bisector; min X is the inner rim's ±π/4 ends (cos45·1); + // Z spans ±(outer·sin45). + const xs = anchors.map((a) => a.x) + const zs = anchors.map((a) => a.z) + expect(Math.max(...xs)).toBeCloseTo(2, 5) + expect(Math.min(...xs)).toBeCloseTo(Math.SQRT1_2, 5) + expect(Math.max(...zs)).toBeCloseTo(Math.SQRT2, 5) + expect(Math.min(...zs)).toBeCloseTo(-Math.SQRT2, 5) + }) + + test('spiral stair contributes a full-circle bounding box', () => { + registerNode(stairDef()) + const nodes = { + sp: node({ + id: 'sp', + type: 'stair', + position: [5, 0, 5], + rotation: 0, + stairType: 'spiral', + width: 1, + innerRadius: 0.5, + sweepAngle: Math.PI * 2, + }), + } + const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'sp') + expect(anchors).toHaveLength(4) + // outerRadius = inner(0.5) + width(1) = 1.5, a full revolution about (5, 5). + const xs = anchors.map((a) => a.x) + const zs = anchors.map((a) => a.z) + expect(Math.max(...xs)).toBeCloseTo(6.5, 2) + expect(Math.min(...xs)).toBeCloseTo(3.5, 2) + expect(Math.max(...zs)).toBeCloseTo(6.5, 2) + expect(Math.min(...zs)).toBeCloseTo(3.5, 2) + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/alignment-anchors.ts b/services/pascal-editor/packages/core/src/services/alignment-anchors.ts new file mode 100644 index 000000000..5032b154a --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/alignment-anchors.ts @@ -0,0 +1,386 @@ +/** + * Node → alignment-anchor adapters. + * + * `alignment.ts` is pure geometry and knows nothing about nodes. This + * module bridges the scene graph to it: it reads a floor-placed kind's + * footprint from the registry and turns it into the bbox anchors the + * resolver matches against. Kept out of `alignment.ts` so that file stays + * registry-free. + * + * All coordinates are XZ meters in the same frame as `node.position` + * (building-local for nodes inside a building). The 3D move producer works + * entirely in that frame, so the resulting guides line up with the cursor. + */ + +import { nodeRegistry } from '../registry' +import type { AnyNode } from '../schema/types' +import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' +import { type AlignmentAnchor, bboxCornerAnchors } from './alignment' + +export type FootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number } + +/** + * Axis-aligned XZ bounding box of a rotated rectangle centred at + * `position`. Mirrors the rotated-corner math the spatial-grid manager + * uses (`getItemFootprint`) so alignment anchors coincide with the + * footprint used for collision / slab elevation. + */ +export function footprintAABBFrom( + position: readonly [number, number, number], + dimensions: readonly [number, number, number], + rotationY: number, +): FootprintAABB { + const [x, , z] = position + const [w, , d] = dimensions + const halfW = w / 2 + const halfD = d / 2 + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [lx, lz] of [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] as const) { + const wx = x + (lx * cos - lz * sin) + const wz = z + (lx * sin + lz * cos) + if (wx < minX) minX = wx + if (wx > maxX) maxX = wx + if (wz < minZ) minZ = wz + if (wz > maxZ) maxZ = wz + } + + return { minX, minZ, maxX, maxZ } +} + +/** The relocatable box footprint for a node, or null when it has none + * (walls / slabs / polygon kinds) or the kind's predicate excludes it + * (e.g. a wall-attached item that doesn't rest on the floor). + * + * Box footprints come from one of two capabilities: `floorPlaced` (kinds + * whose Y is also slab-lifted — columns, items) or `alignmentFootprint` + * with a `box` shape (kinds that align by their footprint but aren't + * floor-coupled — the elevator's outer shaft). A kind whose + * `alignmentFootprint` is an `aabb` (stair) has no centred box, so it's + * resolved directly in `nodeAlignmentAnchors`, not here. */ +function floorFootprint( + node: AnyNode, +): { dimensions: [number, number, number]; rotation: [number, number, number] } | null { + const capabilities = nodeRegistry.get(node.type)?.capabilities + const floorPlaced = capabilities?.floorPlaced + // `footprint` is optional now that floor-placed kinds may instead declare + // composite `footprints` (e.g. stairs); those have no single centred box + // here, so fall through to `alignmentFootprint`. + if (floorPlaced?.footprint) { + if (floorPlaced.applies && !floorPlaced.applies(node)) return null + return floorPlaced.footprint(node) + } + const alignment = capabilities?.alignmentFootprint?.(node) + if (alignment?.shape === 'box') { + return { dimensions: alignment.dimensions, rotation: alignment.rotation } + } + return null +} + +/** + * XZ bounding box a node occupies in plan, unifying the two non-structural + * sources: a relocatable box (`floorFootprint`, covering floor-placed kinds + * and the elevator's alignment box) and a kind that hands back an explicit + * `aabb` because its plan shape isn't a centred rectangle (stair). Returns + * null for kinds with neither. + */ +function alignmentAABB( + node: AnyNode, + nodes?: Readonly>, +): FootprintAABB | null { + const box = footprintAABB(node) + if (box) return box + const alignment = nodeRegistry.get(node.type)?.capabilities?.alignmentFootprint?.(node, nodes) + if (alignment?.shape === 'aabb') { + return { + minX: alignment.minX, + minZ: alignment.minZ, + maxX: alignment.maxX, + maxZ: alignment.maxZ, + } + } + return null +} + +/** XZ footprint AABB of a floor-placed node at its current position, or + * null for kinds without a usable footprint. */ +export function footprintAABB(node: AnyNode): FootprintAABB | null { + const fp = floorFootprint(node) + if (!fp) return null + const position = (node as { position?: [number, number, number] }).position ?? [0, 0, 0] + return footprintAABBFrom(position, fp.dimensions, fp.rotation[1] ?? 0) +} + +/** XZ footprint AABB of a floor-placed node relocated so its centre sits at + * the proposed (x, z). `rotationY` overrides the node's footprint rotation + * (R/T bumps it before the scene commit lands). Null when no footprint. */ +export function footprintAABBAt( + node: AnyNode, + x: number, + z: number, + rotationY?: number, +): FootprintAABB | null { + const fp = floorFootprint(node) + if (!fp) return null + return footprintAABBFrom([x, 0, z], fp.dimensions, rotationY ?? fp.rotation[1] ?? 0) +} + +/** + * Corner anchors for the moving node's footprint relocated so its centre + * sits at the proposed (x, z). Corners only — the moving item aligns by its + * edges, never its centreline. Returns [] when the kind has no footprint. + */ +export function movingFootprintAnchors( + node: AnyNode, + x: number, + z: number, + rotationY?: number, +): AlignmentAnchor[] { + const aabb = footprintAABBAt(node, x, z, rotationY) + if (!aabb) return [] + return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) +} + +function relocatedPlanNode(node: AnyNode, x: number, z: number, rotationY?: number): AnyNode { + const position = (node as { position?: unknown }).position + const y = Array.isArray(position) && typeof position[1] === 'number' ? position[1] : 0 + const relocated: Record = { + ...(node as Record), + position: [x, y, z], + } + + if (rotationY !== undefined && 'rotation' in node) { + const rotation = (node as { rotation?: unknown }).rotation + relocated.rotation = Array.isArray(rotation) + ? [rotation[0] ?? 0, rotationY, rotation[2] ?? 0] + : rotationY + } + + return relocated as AnyNode +} + +/** + * Corner anchors for a moving node relocated to the proposed plan position. + * Covers both the centred-box path (`floorPlaced.footprint` / + * `alignmentFootprint: box`) and explicit AABB footprints such as stairs, + * whose occupied plan bounds depend on children or curved/spiral geometry. + */ +export function movingAlignmentAnchors( + node: AnyNode, + nodes: Readonly> | undefined, + x: number, + z: number, + rotationY?: number, +): AlignmentAnchor[] { + const box = footprintAABBAt(node, x, z, rotationY) + if (box) return bboxCornerAnchors(node.id, box.minX, box.minZ, box.maxX, box.maxZ) + + const alignment = nodeRegistry + .get(node.type) + ?.capabilities?.alignmentFootprint?.(relocatedPlanNode(node, x, z, rotationY), nodes) + + if (alignment?.shape === 'box') { + const aabb = footprintAABBFrom([x, 0, z], alignment.dimensions, alignment.rotation[1] ?? 0) + return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) + } + if (alignment?.shape === 'aabb') { + return bboxCornerAnchors( + node.id, + alignment.minX, + alignment.minZ, + alignment.maxX, + alignment.maxZ, + ) + } + + return [] +} + +/** + * Alignment anchors for a wall segment: the two centerline endpoints + chord + * midpoint, plus — when `thickness` is known — four **face** corner anchors, + * each endpoint offset by ±thickness/2 perpendicular to the wall axis. + * + * The face anchors are what let a footprint align to a wall's *face* rather + * than its centerline: for an axis-aligned wall the two same-side face + * anchors share a constant X (vertical wall) or Z (horizontal wall) running + * the wall's full length, so the point-to-point resolver snaps a moving + * corner flush to the face anywhere along the wall (the perpendicular + * tie-break connects the guide to the nearer face endpoint). A diagonal wall + * gets only its face/centerline endpoints — point-to-point can't represent a + * sloped face line; that's an accepted v1 limitation. + * + * Curve offset is ignored — endpoints are exact and the chord midpoint is + * good enough for v1. Coordinates are the wall's `start` / `end` + * (building-local XZ meters). + */ +export function wallSegmentAnchors( + id: string, + start: readonly [number, number], + end: readonly [number, number], + thickness?: number, +): AlignmentAnchor[] { + const anchors: AlignmentAnchor[] = [ + { nodeId: id, kind: 'corner', x: start[0], z: start[1] }, + { nodeId: id, kind: 'corner', x: end[0], z: end[1] }, + { nodeId: id, kind: 'center', x: (start[0] + end[0]) / 2, z: (start[1] + end[1]) / 2 }, + ] + + if (thickness && thickness > 0) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const len = Math.hypot(dx, dz) + if (len > 1e-6) { + // Perpendicular to the wall axis, scaled to half-thickness. + const half = thickness / 2 + const px = (-dz / len) * half + const pz = (dx / len) * half + for (const [bx, bz] of [start, end] as const) { + anchors.push({ nodeId: id, kind: 'corner', x: bx + px, z: bz + pz }) + anchors.push({ nodeId: id, kind: 'corner', x: bx - px, z: bz - pz }) + } + } + } + + return anchors +} + +/** Each vertex of a polygon (slab / ceiling footprint) as a `corner` anchor. */ +export function polygonAnchors( + id: string, + points: readonly (readonly [number, number])[], +): AlignmentAnchor[] { + return points.map(([x, z]) => ({ nodeId: id, kind: 'corner' as const, x, z })) +} + +/** + * Alignment anchors a node contributes to the candidate pool, dispatched by + * kind: walls / fences → segment endpoints + midpoint; slabs / ceilings → + * polygon vertices; everything else → the corners of its plan bounding box + * (`alignmentAABB`, which covers floor-placed kinds, the elevator's + * alignment box, and the stair's chain / sector footprint). Kinds with no + * usable footprint contribute nothing. + * + * `nodes` is needed only by kinds whose footprint walks siblings / children + * (a straight stair's `stair-segment` chain); every other kind derives its + * anchors from `node` alone. + */ +export function nodeAlignmentAnchors( + node: AnyNode, + nodes?: Readonly>, +): AlignmentAnchor[] { + if (node.type === 'wall' || node.type === 'fence') { + const seg = node as { + id: string + start: [number, number] + end: [number, number] + thickness?: number + } + // Wall thickness is schema-optional (falls back to the geometry default); + // fence always carries one. Either way, pass it through so faces align. + return wallSegmentAnchors(seg.id, seg.start, seg.end, seg.thickness ?? DEFAULT_WALL_THICKNESS) + } + if (node.type === 'slab' || node.type === 'ceiling') { + const poly = (node as { polygon?: [number, number][] }).polygon + return poly ? polygonAnchors(node.id, poly) : [] + } + + const anchors: AlignmentAnchor[] = [] + + // Box footprint (items, columns, shelves, stairs, …). + const aabb = alignmentAABB(node, nodes) + if (aabb) { + anchors.push(...bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)) + } + + // Polyline kinds (duct / pipe / lineset): every path vertex is an anchor, + // so anything dragged snaps to a run's ends and bends. + const path = (node as { path?: unknown }).path + if (Array.isArray(path)) { + for (const p of path as Array<[number, number, number]>) { + anchors.push({ nodeId: node.id, kind: 'corner', x: p[0], z: p[2] }) + } + } + + // Typed ports (fittings, equipment, terminals, run ends): connection points + // are natural alignment targets — line a new run up with an existing collar. + const ports = nodeRegistry.get(node.type)?.ports?.(node) + if (ports) { + for (const port of ports) { + anchors.push({ nodeId: node.id, kind: 'corner', x: port.position[0], z: port.position[2] }) + } + } + + // Position-based kinds with no footprint (e.g. duct fittings): the origin + // itself is a useful centre anchor. + if (!aabb) { + const position = (node as { position?: [number, number, number] }).position + if (Array.isArray(position)) { + anchors.push({ nodeId: node.id, kind: 'center', x: position[0], z: position[2] }) + } + } + + return anchors +} + +/** + * Resolve the level a node belongs to by walking its `parentId` chain, or + * null when it isn't under a level. Inlined here (rather than importing the + * spatial-grid `resolveLevelId`) to keep this services module free of + * hook / store dependencies. + */ +function resolveNodeLevelId( + node: AnyNode, + nodes: Readonly>, +): string | null { + let current: AnyNode | undefined = node + while (current) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId] : undefined + } + return null +} + +/** + * Anchors from every alignable node except `excludeId` — the unified + * candidate pool every move / placement tool resolves against, so any + * draggable object can align to any other (items, walls, fences, slabs, + * ceilings, columns). + * + * When `levelId` is given, nodes that belong to a *different* level are + * dropped. Alignment is XZ-only, so without this a node directly below on + * another floor (e.g. the ground floor while you place on the first) would + * snap and draw a guide even though the two sit at different heights. + * Building-/site-scoped nodes with no level ancestor (e.g. an elevator + * shaft, which is parented to the building and spans every floor) resolve to + * null and stay in the pool so they align on any floor. The 2D floor-plan + * deliberately omits the filter — aligning a wall to the one directly below + * in plan is the whole point of the reference floor. + */ +export function collectAlignmentAnchors( + nodes: Readonly>, + excludeId: string, + levelId?: string | null, +): AlignmentAnchor[] { + const anchors: AlignmentAnchor[] = [] + for (const node of Object.values(nodes)) { + if (!node || node.id === excludeId) continue + if (levelId) { + const nodeLevelId = resolveNodeLevelId(node, nodes) + if (nodeLevelId !== null && nodeLevelId !== levelId) continue + } + anchors.push(...nodeAlignmentAnchors(node, nodes)) + } + return anchors +} diff --git a/services/pascal-editor/packages/core/src/services/alignment.test.ts b/services/pascal-editor/packages/core/src/services/alignment.test.ts new file mode 100644 index 000000000..7eb86e128 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/alignment.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'bun:test' +import { type AlignmentAnchor, bboxAnchors, resolveAlignment } from './alignment' + +function center(nodeId: string, x: number, z: number): AlignmentAnchor { + return { nodeId, kind: 'center', x, z } +} + +function corner(nodeId: string, x: number, z: number): AlignmentAnchor { + return { nodeId, kind: 'corner', x, z } +} + +describe('resolveAlignment', () => { + test('returns empty when no candidates within threshold', () => { + const result = resolveAlignment({ + moving: [center('m', 0, 0)], + candidates: [center('a', 1, 1)], + threshold: 0.5, + }) + expect(result.guides).toEqual([]) + expect(result.snap).toBeNull() + }) + + test('snaps moving anchor onto candidate X when within threshold', () => { + const result = resolveAlignment({ + moving: [center('m', 0.03, 5)], + candidates: [center('a', 0, 2)], + threshold: 0.1, + }) + expect(result.snap).toEqual({ dx: -0.03, dz: 0 }) + expect(result.guides).toHaveLength(1) + expect(result.guides[0]!.axis).toBe('x') + expect(result.guides[0]!.coord).toBe(0) + expect(result.guides[0]!.from.z).toBe(2) + expect(result.guides[0]!.to.z).toBe(5) + }) + + test('snaps both axes when both match', () => { + const result = resolveAlignment({ + moving: [center('m', 0.03, 0.04)], + candidates: [center('a', 0, 0)], + threshold: 0.1, + }) + expect(result.snap).toEqual({ dx: -0.03, dz: -0.04 }) + expect(result.guides).toHaveLength(2) + }) + + test('picks closest candidate per axis', () => { + const result = resolveAlignment({ + moving: [center('m', 0.08, 0)], + candidates: [center('a', 0, 5), center('b', 0.1, 5), center('c', 0.05, 10)], + threshold: 0.1, + }) + // |0.1 - 0.08| = 0.02 wins over |0.05 - 0.08| = 0.03 and |0 - 0.08| = 0.08 + expect(result.snap?.dx).toBeCloseTo(0.02, 10) + expect(result.guides[0]!.candidateNodeId).toBe('b') + }) + + test('ties on the matched axis break toward the nearest perpendicular anchor', () => { + const result = resolveAlignment({ + moving: [corner('m', 0.02, 4)], + candidates: [corner('far', 0, 0), corner('near', 0, 5)], + threshold: 0.1, + }) + // Both share X (Δx = 0.02); 'near' (z=5) is closer to the moving z=4 than + // 'far' (z=0), so the guide connects to the nearest real anchor. + expect(result.guides[0]!.candidateNodeId).toBe('near') + }) + + test('threshold = 0 disables alignment', () => { + const result = resolveAlignment({ + moving: [center('m', 0, 0)], + candidates: [center('a', 0, 0)], + threshold: 0, + }) + expect(result.guides).toEqual([]) + expect(result.snap).toBeNull() + }) + + test('distance is the perpendicular gap to the matched axis', () => { + const result = resolveAlignment({ + moving: [center('m', 0.02, 3)], + candidates: [center('a', 0, 0)], + threshold: 0.1, + }) + // After snap: moving at (0, 3). X guide runs along x=0 from z=0 to z=3. + expect(result.guides[0]!.distance).toBeCloseTo(3, 10) + }) +}) + +describe('bboxAnchors', () => { + test('returns 9 anchors with correct kinds and positions', () => { + const anchors = bboxAnchors('node', 0, 0, 2, 4) + expect(anchors).toHaveLength(9) + const corners = anchors.filter((a) => a.kind === 'corner') + const edges = anchors.filter((a) => a.kind === 'edge-mid') + const centers = anchors.filter((a) => a.kind === 'center') + expect(corners).toHaveLength(4) + expect(edges).toHaveLength(4) + expect(centers).toHaveLength(1) + expect(centers[0]).toEqual({ nodeId: 'node', kind: 'center', x: 1, z: 2 }) + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/alignment.ts b/services/pascal-editor/packages/core/src/services/alignment.ts new file mode 100644 index 000000000..a7903957d --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/alignment.ts @@ -0,0 +1,308 @@ +/** + * Pure alignment-guide resolver — no React, no DOM, no scene access. + * + * Given a moving object's anchor points at its proposed position and a + * pool of candidate anchors from nearby static objects, returns: + * - the best per-axis matches as `Guide` rendering primitives, and + * - an optional `{ dx, dz }` snap delta the caller can apply. + * + * Anchors are 2D points on the floor plane (XZ, in world meters). The + * resolver picks at most one match per axis: the smallest |Δx| match + * snaps X; the smallest |Δz| match snaps Z. This mirrors Figma's + * behaviour — guides appear along the matched axes, regardless of how + * many neighbours could have matched. + * + * Two guides max per call keeps the visual signal sharp at the cost of + * not surfacing every possible alignment at once. Multi-guide ("this + * lines up with three things") is intentionally out of scope for v1. + */ + +export type AnchorKind = 'corner' | 'edge-mid' | 'center' + +export type AlignmentAnchor = { + /** Owning node id — informational; resolver does not use it. */ + nodeId: string + kind: AnchorKind + x: number + z: number +} + +export type AlignmentGuideAxis = 'x' | 'z' + +/** + * Rendering primitive — a guide line on the floor plane. + * + * `axis === 'x'`: vertical guide. Both endpoints share `coord` as their X. + * `axis === 'z'`: horizontal guide. Both endpoints share `coord` as their Z. + * + * The line spans from the matched candidate anchor to the moving anchor + * after snap. Renderers extend visually beyond the endpoints if they want + * Figma-style "infinite line" feel. + */ +export type AlignmentGuide = { + axis: AlignmentGuideAxis + coord: number + from: { x: number; z: number } + to: { x: number; z: number } + /** The matched candidate anchor — the fixed end the moving point aligns to. */ + anchor: { x: number; z: number } + movingAnchorKind: AnchorKind + candidateAnchorKind: AnchorKind + candidateNodeId: string + /** Perpendicular distance between the two anchors (used by the distance pill). */ + distance: number +} + +export type ResolveAlignmentInput = { + /** Anchors of the moving node, positioned at the proposed (pre-snap) location. */ + moving: readonly AlignmentAnchor[] + /** Anchors from every other candidate node the caller has already filtered. */ + candidates: readonly AlignmentAnchor[] + /** + * Max |Δ| (meters) for an anchor pair to count as a match. Typically + * derived from a screen-pixel budget × current units-per-pixel so the + * snap feel is zoom-invariant. + */ + threshold: number +} + +export type ResolveAlignmentResult = { + guides: AlignmentGuide[] + /** + * Delta the caller should add to the moving node's planar position so + * its anchors land on the matched axes. `null` when no axis matched. + */ + snap: { dx: number; dz: number } | null +} + +const EMPTY: ResolveAlignmentResult = { guides: [], snap: null } + +/** Forward rotation: local XZ → world XZ for a node whose parent has + * position `bx,_,bz` and rotation-Y `rotY` (radians). Matches the + * transform used throughout the editor's tools / floor-plan. */ +function localToWorld( + x: number, + z: number, + bx: number, + bz: number, + cos: number, + sin: number, +): { x: number; z: number } { + return { + x: bx + x * cos + z * sin, + z: bz - x * sin + z * cos, + } +} + +function transformAnchorToWorld( + anchor: AlignmentAnchor, + bx: number, + bz: number, + cos: number, + sin: number, +): AlignmentAnchor { + const w = localToWorld(anchor.x, anchor.z, bx, bz, cos, sin) + return { nodeId: anchor.nodeId, kind: anchor.kind, x: w.x, z: w.z } +} + +export type BuildingPose = { + position: readonly [number, number, number] + rotationY: number +} + +export type ResolveAlignmentInBuildingResult = { + /** Guides in WORLD coordinates. Renderers must be in a world-space group. */ + guides: AlignmentGuide[] + /** Snap delta in the BUILDING-LOCAL frame, ready to add to a local position. */ + snap: { dx: number; dz: number } | null +} + +/** + * Resolve alignment in WORLD space while accepting BUILDING-LOCAL anchors. + * + * Why this exists: the floor-plan grid lives in world XZ (rendered outside + * the rotated scene group), so alignment must follow the same axes — + * otherwise rotating a building drags the alignment guides off the visible + * grid and onto the rotated wall's local axes (the bug the user hit). The + * resolver itself is frame-agnostic; this wrapper just transforms anchors + * to world, resolves, then rotates the snap delta back into building-local + * so callers can add it to a local position without further math. + * + * `pose === null` → resolve in the caller's frame as-is (no transform). + */ +export function resolveAlignmentInBuildingWorld(input: { + moving: readonly AlignmentAnchor[] + candidates: readonly AlignmentAnchor[] + threshold: number + pose: BuildingPose | null +}): ResolveAlignmentInBuildingResult { + const { moving, candidates, threshold, pose } = input + if (!pose) { + return resolveAlignment({ moving, candidates, threshold }) + } + const cos = Math.cos(pose.rotationY) + const sin = Math.sin(pose.rotationY) + const bx = pose.position[0] + const bz = pose.position[2] + const movingWorld = moving.map((a) => transformAnchorToWorld(a, bx, bz, cos, sin)) + const candidatesWorld = candidates.map((a) => transformAnchorToWorld(a, bx, bz, cos, sin)) + const result = resolveAlignment({ + moving: movingWorld, + candidates: candidatesWorld, + threshold, + }) + if (!result.snap) return { guides: result.guides, snap: null } + // World → local rotation (orthogonal matrix → transpose). The inverse of + // `localToWorld` above maps (dx_world, dz_world) → (dx_local, dz_local). + const dxW = result.snap.dx + const dzW = result.snap.dz + const dxL = dxW * cos - dzW * sin + const dzL = dxW * sin + dzW * cos + return { guides: result.guides, snap: { dx: dxL, dz: dzL } } +} + +export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignmentResult { + const { moving, candidates, threshold } = input + if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return EMPTY + + // Best match per axis: among all candidate anchors within `threshold` of + // the moving anchor on the matched axis, pick the one CLOSEST in the + // perpendicular direction — so the guide always connects to the visually + // nearest actual point of the candidate. Primary delta only breaks perp + // ties. + // + // Why perp-first: a wall pre-rotation contributes anchors that share an + // exact X (vertical wall) or Z (horizontal wall), so primary deltas tie + // and perp picks the nearer endpoint. Post-rotation, the same wall's + // anchors are at slightly-different world coordinates after a float + // rotation — primary deltas differ by tiny amounts and a primary-first + // tie-break would lock onto whichever happens to be marginally tighter, + // often the far endpoint. Perp-first keeps the "closest point of + // reference" behaviour stable through rotation. + type Best = { + delta: number + primary: number + perp: number + m: AlignmentAnchor + c: AlignmentAnchor + } + let bestX: Best | null = null + let bestZ: Best | null = null + + for (const m of moving) { + for (const c of candidates) { + const dx = c.x - m.x + const dz = c.z - m.z + const adx = Math.abs(dx) + const adz = Math.abs(dz) + if ( + adx <= threshold && + (bestX === null || adz < bestX.perp || (adz === bestX.perp && adx < bestX.primary)) + ) { + bestX = { delta: dx, primary: adx, perp: adz, m, c } + } + if ( + adz <= threshold && + (bestZ === null || adx < bestZ.perp || (adx === bestZ.perp && adz < bestZ.primary)) + ) { + bestZ = { delta: dz, primary: adz, perp: adx, m, c } + } + } + } + + if (!bestX && !bestZ) return EMPTY + + const dxSnap = bestX?.delta ?? 0 + const dzSnap = bestZ?.delta ?? 0 + const guides: AlignmentGuide[] = [] + + if (bestX) { + // X-axis match: vertical guide at x = bestX.c.x. The moving anchor + // ends up at (c.x, m.z + dzSnap). Span the line between them. + const snappedMz = bestX.m.z + dzSnap + const z1 = Math.min(bestX.c.z, snappedMz) + const z2 = Math.max(bestX.c.z, snappedMz) + guides.push({ + axis: 'x', + coord: bestX.c.x, + from: { x: bestX.c.x, z: z1 }, + to: { x: bestX.c.x, z: z2 }, + anchor: { x: bestX.c.x, z: bestX.c.z }, + movingAnchorKind: bestX.m.kind, + candidateAnchorKind: bestX.c.kind, + candidateNodeId: bestX.c.nodeId, + distance: Math.abs(snappedMz - bestX.c.z), + }) + } + + if (bestZ) { + const snappedMx = bestZ.m.x + dxSnap + const x1 = Math.min(bestZ.c.x, snappedMx) + const x2 = Math.max(bestZ.c.x, snappedMx) + guides.push({ + axis: 'z', + coord: bestZ.c.z, + from: { x: x1, z: bestZ.c.z }, + to: { x: x2, z: bestZ.c.z }, + anchor: { x: bestZ.c.x, z: bestZ.c.z }, + movingAnchorKind: bestZ.m.kind, + candidateAnchorKind: bestZ.c.kind, + candidateNodeId: bestZ.c.nodeId, + distance: Math.abs(snappedMx - bestZ.c.x), + }) + } + + return { guides, snap: { dx: dxSnap, dz: dzSnap } } +} + +// ─── Anchor extractors (pure) ───────────────────────────────────────── + +/** + * Produces the 9 standard anchors for an axis-aligned bounding box on the + * floor plane: 4 corners, 4 edge midpoints, 1 center. Suitable for any + * floor-plan entity whose footprint can be expressed as a bbox. + * + * Caller is responsible for computing the bbox — the resolver doesn't + * care how (per-kind dimensions, SVG getBBox(), etc.). + */ +export function bboxAnchors( + nodeId: string, + minX: number, + minZ: number, + maxX: number, + maxZ: number, +): AlignmentAnchor[] { + const cx = (minX + maxX) / 2 + const cz = (minZ + maxZ) / 2 + return [ + { nodeId, kind: 'corner', x: minX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: maxZ }, + { nodeId, kind: 'corner', x: minX, z: maxZ }, + { nodeId, kind: 'edge-mid', x: cx, z: minZ }, + { nodeId, kind: 'edge-mid', x: maxX, z: cz }, + { nodeId, kind: 'edge-mid', x: cx, z: maxZ }, + { nodeId, kind: 'edge-mid', x: minX, z: cz }, + { nodeId, kind: 'center', x: cx, z: cz }, + ] +} + +/** + * The 4 corner anchors of a bbox — edges only, no edge-midpoints or center. + * Used where alignment should lock to an object's edges (left/right/front/ + * back), never its centreline. + */ +export function bboxCornerAnchors( + nodeId: string, + minX: number, + minZ: number, + maxX: number, + maxZ: number, +): AlignmentAnchor[] { + return [ + { nodeId, kind: 'corner', x: minX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: maxZ }, + { nodeId, kind: 'corner', x: minX, z: maxZ }, + ] +} diff --git a/services/pascal-editor/packages/core/src/services/drag-session.test.ts b/services/pascal-editor/packages/core/src/services/drag-session.test.ts new file mode 100644 index 000000000..5852dc773 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/drag-session.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../registry/registry' +import type { AnyNodeDefinition, DragAction, Relations, SceneApi } from '../registry/types' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { createDragSession } from './drag-session' + +const id = (s: string) => s as AnyNodeId + +function makeSpyScene(initial: Record = {}): SceneApi & { + _calls: { + pauseHistory: number + resumeHistory: number + restoreAll: number + markedDirty: AnyNodeId[] + updated: Array<[AnyNodeId, Partial]> + } +} { + const calls = { + pauseHistory: 0, + resumeHistory: 0, + restoreAll: 0, + markedDirty: [] as AnyNodeId[], + updated: [] as Array<[AnyNodeId, Partial]>, + } + const nodes = { ...initial } + return { + get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'], + nodes: () => nodes as Readonly>, + update: (nid, patch) => { + calls.updated.push([nid, patch]) + const existing = nodes[nid as string] + if (existing) nodes[nid as string] = { ...existing, ...patch } as AnyNode + }, + upsert: (n: AnyNode) => { + nodes[n.id as string] = n + return n.id + }, + delete: (nid) => { + delete nodes[nid as string] + }, + restore: () => {}, + restoreAll: () => { + calls.restoreAll += 1 + }, + markDirty: (nid) => { + calls.markedDirty.push(nid) + }, + pauseHistory: () => { + calls.pauseHistory += 1 + }, + resumeHistory: () => { + calls.resumeHistory += 1 + }, + getSubtree: () => null, + cloneNodesInto: () => null, + _calls: calls, + } +} + +function makeDef(kind: string, relations?: Relations): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as any, + category: 'utility', + defaults: () => ({}) as any, + capabilities: {}, + relations, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } +} + +function makeAction(): DragAction<{ id: AnyNodeId }, { x: number }> { + return { + begin: ({ node }) => ({ id: node?.id ?? id('default') }), + preview: (_ctx, point) => ({ x: point[0] }), + apply: (_draft, ctx) => [ctx.id], + cancel: () => {}, + } +} + +describe('createDragSession', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('start pauses history; commit resumes', () => { + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene) + session.start({ point: [0, 0] }) + expect(scene._calls.pauseHistory).toBe(1) + expect(scene._calls.resumeHistory).toBe(0) + session.commit() + expect(scene._calls.resumeHistory).toBe(1) + }) + + test('cancel resumes history and calls restoreAll', () => { + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene) + session.start({ point: [0, 0] }) + session.cancel() + expect(scene._calls.resumeHistory).toBe(1) + expect(scene._calls.restoreAll).toBe(1) + }) + + test('move runs preview + apply and marks the returned id dirty', () => { + const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any }) + const session = createDragSession(makeAction(), scene) + session.start({ point: [0, 0], node: { id: id('a') } as any }) + session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false }) + expect(session.getDraft()).toEqual({ x: 1 }) + expect(scene._calls.markedDirty).toContain(id('a')) + }) + + test('snap callback is invoked when defined', () => { + const action: DragAction<{ id: AnyNodeId }, { x: number }> = { + ...makeAction(), + snap: (draft) => ({ x: Math.round(draft.x) }), + } + const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any }) + const session = createDragSession(action, scene) + session.start({ point: [0, 0], node: { id: id('a') } as any }) + session.move([0.7, 0], { shift: false, alt: false, ctrl: false, meta: false }) + expect(session.getDraft()).toEqual({ x: 1 }) + }) + + test('commit returns false when action.commit returns false; calls action.cancel and restoreAll', () => { + const cancelSpy = mock(() => {}) + const action: DragAction<{ id: AnyNodeId }, { x: number }> = { + ...makeAction(), + cancel: cancelSpy, + commit: () => false, + } + const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any }) + const session = createDragSession(action, scene) + session.start({ point: [0, 0], node: { id: id('a') } as any }) + session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false }) + const result = session.commit() + expect(result).toBe(false) + expect(cancelSpy).toHaveBeenCalledTimes(1) + expect(scene._calls.restoreAll).toBe(1) + }) + + test('move is a no-op when session is not active', () => { + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene) + session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false }) + expect(scene._calls.markedDirty.length).toBe(0) + }) + + test('repeated start is a no-op (re-entry guard)', () => { + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene) + session.start({ point: [0, 0] }) + session.start({ point: [99, 99] }) + expect(scene._calls.pauseHistory).toBe(1) // only one pause + }) + + test('dispose mid-drag cancels and cleans up', () => { + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene) + session.start({ point: [0, 0] }) + expect(session.isActive()).toBe(true) + session.dispose() + expect(session.isActive()).toBe(false) + expect(scene._calls.resumeHistory).toBe(1) + expect(scene._calls.restoreAll).toBe(1) + }) + + test('dispose when inactive is a no-op', () => { + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene) + session.dispose() + expect(scene._calls.pauseHistory).toBe(0) + expect(scene._calls.resumeHistory).toBe(0) + }) + + test('onCommit callback fires on successful commit', () => { + const onCommit = mock(() => {}) + const onCancel = mock(() => {}) + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene, { onCommit, onCancel }) + session.start({ point: [0, 0] }) + session.commit() + expect(onCommit).toHaveBeenCalledTimes(1) + expect(onCancel).toHaveBeenCalledTimes(0) + }) + + test('onCancel callback fires on explicit cancel', () => { + const onCommit = mock(() => {}) + const onCancel = mock(() => {}) + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene, { onCommit, onCancel }) + session.start({ point: [0, 0] }) + session.cancel() + expect(onCommit).toHaveBeenCalledTimes(0) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + test('dispose does NOT fire onCancel (silent cleanup)', () => { + const onCommit = mock(() => {}) + const onCancel = mock(() => {}) + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene, { onCommit, onCancel }) + session.start({ point: [0, 0] }) + session.dispose() + expect(onCommit).toHaveBeenCalledTimes(0) + expect(onCancel).toHaveBeenCalledTimes(0) + expect(scene._calls.resumeHistory).toBe(1) + expect(scene._calls.restoreAll).toBe(1) + }) + + test('dirty cascade fires once per id even across multiple move ticks', () => { + // Register a kind with no relations — cascade returns just {startId}. + registerNode(makeDef('thing')) + const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any }) + const session = createDragSession(makeAction(), scene) + session.start({ point: [0, 0], node: { id: id('a') } as any }) + session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false }) + session.move([2, 0], { shift: false, alt: false, ctrl: false, meta: false }) + session.move([3, 0], { shift: false, alt: false, ctrl: false, meta: false }) + // a is marked once, not three times + expect(scene._calls.markedDirty.filter((mid) => mid === id('a')).length).toBe(1) + }) + + test('dirty cascade follows hosts relations from the registry', () => { + registerNode(makeDef('wall', { hosts: ['door'] })) + registerNode(makeDef('door')) + const scene = makeSpyScene({ + w: { id: id('w'), type: 'wall', children: [id('d')] } as any, + d: { id: id('d'), type: 'door', parentId: id('w') } as any, + }) + const action: DragAction<{ id: AnyNodeId }, { x: number }> = { + begin: () => ({ id: id('w') }), + preview: (_ctx, point) => ({ x: point[0] }), + apply: (_draft, ctx) => [ctx.id], + cancel: () => {}, + } + const session = createDragSession(action, scene) + session.start({ point: [0, 0], node: { id: id('w') } as any }) + session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false }) + // both wall and door marked dirty + expect(scene._calls.markedDirty).toContain(id('w')) + expect(scene._calls.markedDirty).toContain(id('d')) + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/drag-session.ts b/services/pascal-editor/packages/core/src/services/drag-session.ts new file mode 100644 index 000000000..b58ae6e93 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/drag-session.ts @@ -0,0 +1,154 @@ +import { type ChildQuery, cascadeDirty, type SpatialQuery } from '../registry/relations-resolver' +import type { DragAction, Modifiers, SceneApi } from '../registry/types' +import type { AnyNode, AnyNodeId } from '../schema/types' +import type { Vec2 } from './snap' + +/** + * Pure orchestrator for a single `DragAction` lifecycle: + * begin → (preview → snap? → apply → cascade dirty)* → commit | cancel + * + * Bracketed by `pauseHistory()` / `resumeHistory()` so the entire drag is one + * undo step. The React hook (`useDragAction` in `@pascal-app/editor`) wraps + * this with event subscriptions; tests drive it directly. + */ + +export type DragSessionInput = { + node?: AnyNode + point: Vec2 + handleId?: string + modifiers?: Modifiers +} + +export type DragSessionOptions = { + spatialQuery?: SpatialQuery + childQuery?: ChildQuery + /** Called once the session terminates via `commit()`. */ + onCommit?: () => void + /** Called once the session terminates via `cancel()` or `dispose()`. */ + onCancel?: () => void +} + +export type DragSession = { + /** Begin the drag — pause history, capture ctx via `action.begin`. */ + start: (input: DragSessionInput) => void + /** Per-pointer-move tick — run preview/snap/apply and cascade dirty marks. */ + move: (point: Vec2, modifiers: Modifiers) => void + /** Pointer-up / discrete commit. Returns true if `action.commit` agreed. */ + commit: () => boolean + /** Pointer-cancel / Esc / external abort — restores all touched nodes. */ + cancel: () => void + /** Returns the latest draft `apply` produced (or null before first move). */ + getDraft: () => Draft | null + isActive: () => boolean + /** Idempotent cleanup. If active, restores scene state and resumes + * history, but does **not** fire `onCancel`. Use for React-effect + * teardown — onCancel would re-trigger the parent's state machine and + * break StrictMode's double-mount cycle. Esc / external aborts must + * still call `cancel()` directly. */ + dispose: () => void +} + +const EMPTY_MODIFIERS: Modifiers = { shift: false, alt: false, ctrl: false, meta: false } + +export function createDragSession( + action: DragAction, + scene: SceneApi, + options: DragSessionOptions = {}, +): DragSession { + let active = false + let ctx: Ctx | null = null + let draft: Draft | null = null + let dirtyMarked = new Set() + + function markWithCascade(id: AnyNodeId): void { + if (dirtyMarked.has(id)) return + const ids = cascadeDirty(id, { + scene, + spatialQuery: options.spatialQuery, + childQuery: options.childQuery, + }) + for (const dirtyId of ids) { + if (!dirtyMarked.has(dirtyId)) { + scene.markDirty(dirtyId) + dirtyMarked.add(dirtyId) + } + } + } + + function terminate(committed: boolean): void { + if (!active) return + active = false + ctx = null + draft = null + dirtyMarked = new Set() + scene.resumeHistory() + if (committed) options.onCommit?.() + else options.onCancel?.() + } + + return { + start(input) { + if (active) return // ignore re-entry + scene.pauseHistory() + ctx = action.begin({ + node: input.node, + point: input.point, + handleId: input.handleId, + modifiers: input.modifiers ?? EMPTY_MODIFIERS, + }) + active = true + }, + + move(point, modifiers) { + if (!active || ctx == null) return + let next = action.preview(ctx, point, modifiers) + if (action.snap) { + next = action.snap(next, ctx, undefined) + } + draft = next + const dirtyIds = action.apply(next, ctx, scene) + for (const id of dirtyIds) markWithCascade(id) + }, + + commit() { + if (!active || ctx == null) return false + const ok = action.commit?.(draft as Draft, ctx, scene) ?? true + if (!ok) { + action.cancel(ctx, scene) + scene.restoreAll() + } + terminate(ok) + return ok + }, + + cancel() { + if (!active || ctx == null) return + action.cancel(ctx, scene) + scene.restoreAll() + terminate(false) + }, + + getDraft() { + return draft + }, + + isActive() { + return active + }, + + dispose() { + if (active && ctx != null) { + action.cancel(ctx, scene) + scene.restoreAll() + // Silent terminate: no onCancel. The caller (e.g. useDragAction's + // effect cleanup) is reacting to the parent unmounting and would + // loop the state machine if onCancel re-set the parent's state. + active = false + ctx = null + draft = null + dirtyMarked = new Set() + scene.resumeHistory() + } + }, + } +} diff --git a/services/pascal-editor/packages/core/src/services/hosting.test.ts b/services/pascal-editor/packages/core/src/services/hosting.test.ts new file mode 100644 index 000000000..55934aeca --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/hosting.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../registry/registry' +import type { AnyNodeDefinition, Capabilities, SceneApi } from '../registry/types' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { + canAttach, + canHostOnTop, + clampYToHostTop, + getSurface, + getTopSurfaceHeight, + MAX_HOST_DEPTH, + pickHost, +} from './hosting' + +const id = (s: string) => s as AnyNodeId + +function makeItem(idStr: string, attachTo?: 'wall' | 'wall-side' | 'ceiling'): AnyNode { + return { + id: id(idStr), + type: 'item', + parentId: null, + visible: true, + asset: attachTo ? { attachTo } : {}, + } as unknown as AnyNode +} + +function makeDef( + kind: string, + capabilities: Capabilities = {}, + overrides: Partial = {}, +): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as any, + category: 'utility', + defaults: () => ({}) as any, + capabilities, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + ...overrides, + } +} + +function makeNode(kind: string, idStr: string, parentId: string | null = null): AnyNode { + return { + id: id(idStr), + type: kind, + parentId: parentId ? id(parentId) : null, + visible: true, + } as unknown as AnyNode +} + +function makeFakeScene(nodes: Record): SceneApi { + return { + get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'], + nodes: () => nodes as Readonly>, + update: () => {}, + upsert: () => id(''), + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +describe('canAttach', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('rejects self-host', () => { + const scene = makeFakeScene({ a: makeNode('thing', 'a') }) + const result = canAttach(id('a'), id('a'), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('self-host') + }) + + test('rejects missing host', () => { + const scene = makeFakeScene({ a: makeNode('thing', 'a') }) + const result = canAttach(id('a'), id('missing'), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('host-missing') + }) + + test('detects cycle when host is a descendant of child', () => { + // child=a, host=b, b's parent chain leads back to a → cycle. + const scene = makeFakeScene({ + a: makeNode('thing', 'a'), + b: makeNode('thing', 'b', 'c'), + c: makeNode('thing', 'c', 'a'), // c.parent = a, b.parent = c → cycle if a → b + }) + const result = canAttach(id('a'), id('b'), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('cycle') + }) + + test('rejects when chain would exceed MAX_HOST_DEPTH', () => { + const nodes: Record = {} + for (let i = 0; i <= MAX_HOST_DEPTH; i++) { + nodes[`n${i}`] = makeNode('thing', `n${i}`, i === 0 ? null : `n${i - 1}`) + } + // n6 is already MAX_HOST_DEPTH deep — attaching n_new beneath it would + // push the child to MAX_HOST_DEPTH + 1. + nodes.candidate = makeNode('thing', 'candidate') + const scene = makeFakeScene(nodes) + const result = canAttach(id('candidate'), id(`n${MAX_HOST_DEPTH}`), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('depth-exceeded') + }) + + test('accepts attach when chain stays within MAX_HOST_DEPTH', () => { + const scene = makeFakeScene({ + root: makeNode('thing', 'root'), + candidate: makeNode('thing', 'candidate'), + }) + expect(canAttach(id('candidate'), id('root'), scene).ok).toBe(true) + }) + + test('rejects host kind not in child def.hostable.parents', () => { + registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } })) + const scene = makeFakeScene({ + s: makeNode('shelf', 's'), + ceiling: makeNode('ceiling', 'ceiling'), + }) + const result = canAttach(id('s'), id('ceiling'), scene) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe('kind-not-allowed') + }) + + test('accepts when host kind is in parents', () => { + registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } })) + const scene = makeFakeScene({ + s: makeNode('shelf', 's'), + w: makeNode('wall', 'w'), + }) + expect(canAttach(id('s'), id('w'), scene).ok).toBe(true) + }) + + test('no def or no hostable.parents = no kind restriction', () => { + // Some kinds (e.g. items via catalog) defer to runtime checks instead of + // declaring parents up front. canAttach should not block them. + const scene = makeFakeScene({ + i: makeNode('item', 'i'), + w: makeNode('wall', 'w'), + }) + expect(canAttach(id('i'), id('w'), scene).ok).toBe(true) + }) + + test('allows missing child (placement preview before commit)', () => { + const scene = makeFakeScene({ w: makeNode('wall', 'w') }) + expect(canAttach(id('future-child'), id('w'), scene).ok).toBe(true) + }) +}) + +describe('getSurface / getTopSurfaceHeight', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('returns null when host has no registered def', () => { + const node = makeNode('mystery', 'm') + expect(getSurface(node)).toBeNull() + expect(getTopSurfaceHeight(node)).toBeNull() + }) + + test('returns surface config when declared', () => { + registerNode( + makeDef('table', { + surfaces: { top: { height: 0.74 } }, + }), + ) + const t = makeNode('table', 't') + expect(getSurface(t)?.top?.height).toBe(0.74) + expect(getTopSurfaceHeight(t)).toBe(0.74) + }) + + test('evaluates function-valued height with the node', () => { + registerNode( + makeDef('shelf', { + surfaces: { + top: { + height: (n: any) => (n.id === id('high') ? 1.8 : 0.3), + }, + }, + }), + ) + expect(getTopSurfaceHeight(makeNode('shelf', 'high'))).toBe(1.8) + expect(getTopSurfaceHeight(makeNode('shelf', 'low'))).toBe(0.3) + }) +}) + +describe('clampYToHostTop', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('clamps to top when host has one', () => { + registerNode(makeDef('table', { surfaces: { top: { height: 0.74 } } })) + expect(clampYToHostTop(makeNode('table', 't'), 5)).toBe(0.74) + }) + + test('passes through when host has no top surface', () => { + registerNode(makeDef('plain')) + expect(clampYToHostTop(makeNode('plain', 'p'), 5)).toBe(5) + }) +}) + +describe('pickHost', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('returns first candidate that has hostable capability', () => { + registerNode(makeDef('slab', { hostable: { parents: ['*'] } })) + registerNode(makeDef('item')) // no hostable + const candidates = [makeNode('item', 'i'), makeNode('slab', 's')] + const picked = pickHost({ + point: [0, 0, 0], + candidates, + placedKind: 'chair', + }) + expect(picked?.id).toBe(id('s')) + }) + + test('returns null when nothing in candidates is hostable', () => { + registerNode(makeDef('item')) + const candidates = [makeNode('item', 'i')] + expect(pickHost({ point: [0, 0, 0], candidates, placedKind: 'chair' })).toBeNull() + }) + + test('hitTest can reject hostable candidates', () => { + registerNode(makeDef('slab', { hostable: { parents: ['*'] } })) + const candidates = [makeNode('slab', 's1'), makeNode('slab', 's2')] + const picked = pickHost({ + point: [0, 0, 0], + candidates, + placedKind: 'chair', + hitTest: (host) => host.id === id('s2'), + }) + expect(picked?.id).toBe(id('s2')) + }) + + test('excludes ceiling-mounted hosts (ceiling fan cannot be a top surface)', () => { + registerNode(makeDef('item', { hostable: { parents: ['*'] } })) + const candidates = [makeItem('fan', 'ceiling'), makeItem('table')] + const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' }) + expect(picked?.id).toBe(id('table')) + }) + + test('keeps wall-mounted hosts (wall shelf still hosts)', () => { + registerNode(makeDef('item', { hostable: { parents: ['*'] } })) + const candidates = [makeItem('shelf', 'wall')] + const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' }) + expect(picked?.id).toBe(id('shelf')) + }) +}) + +describe('canHostOnTop', () => { + test('rejects ceiling-attachTo hosts', () => { + expect(canHostOnTop(makeItem('fan', 'ceiling'))).toBe(false) + }) + + test('accepts wall / wall-side / floor (undefined) hosts', () => { + expect(canHostOnTop(makeItem('shelf', 'wall'))).toBe(true) + expect(canHostOnTop(makeItem('sconce', 'wall-side'))).toBe(true) + expect(canHostOnTop(makeItem('table'))).toBe(true) + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/hosting.ts b/services/pascal-editor/packages/core/src/services/hosting.ts new file mode 100644 index 000000000..9394f586a --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/hosting.ts @@ -0,0 +1,158 @@ +import { nodeRegistry } from '../registry/registry' +import type { SceneApi, SurfacesConfig } from '../registry/types' +import type { AnyNode, AnyNodeId } from '../schema/types' + +/** + * Maximum depth a node tree can host. Guards items-on-items-on-items chains + * from growing pathological — pre-Phase-1 the editor had no cap. Set high + * enough to allow legitimate stacking (chair on platform on truck on floor) + * while preventing AI/plugin-generated runaway. + */ +export const MAX_HOST_DEPTH = 6 + +export type Vec3 = readonly [number, number, number] + +export type AttachError = + | { kind: 'self-host'; nodeId: AnyNodeId } + | { kind: 'cycle'; nodeId: AnyNodeId; hostId: AnyNodeId } + | { kind: 'depth-exceeded'; depth: number; max: number } + | { kind: 'host-missing'; hostId: AnyNodeId } + | { kind: 'kind-not-allowed'; hostKind: string; allowed: readonly string[] } + +export type AttachResult = { ok: true } | { ok: false; error: AttachError } + +/** + * Validates that attaching `child` to `host` is safe and either returns an + * actionable error or signals OK. Does NOT mutate the scene — callers apply + * the patch after a successful check. + * + * Rules: + * - A node cannot host itself. + * - The hosting chain (child → host → host.parent → ...) must not contain + * `child` (cycle prevention). + * - The resulting chain must not exceed {@link MAX_HOST_DEPTH}. + * - If the child's NodeDefinition declares `capabilities.hostable.parents`, + * `host.type` must appear in that list. + */ +export function canAttach(childId: AnyNodeId, hostId: AnyNodeId, scene: SceneApi): AttachResult { + if (childId === hostId) { + return { ok: false, error: { kind: 'self-host', nodeId: childId } } + } + + const host = scene.get(hostId) + if (!host) { + return { ok: false, error: { kind: 'host-missing', hostId } } + } + + const child = scene.get(childId) + if (!child) { + // No child node yet — likely a placement preview. Allow attach to proceed; + // the caller is responsible for ensuring child exists before commit. + return checkDepth(hostId, scene) + } + + const childDef = nodeRegistry.get(child.type) + const allowed = childDef?.capabilities.hostable?.parents + if (allowed && allowed.length > 0 && !(allowed as readonly string[]).includes(host.type)) { + return { + ok: false, + error: { kind: 'kind-not-allowed', hostKind: host.type, allowed }, + } + } + + // Cycle: walk host's ancestors and reject if we hit the child. + let cursor: AnyNode | undefined = host + while (cursor) { + if (cursor.id === childId) { + return { ok: false, error: { kind: 'cycle', nodeId: childId, hostId } } + } + cursor = cursor.parentId ? scene.get(cursor.parentId as AnyNodeId) : undefined + } + + return checkDepth(hostId, scene) +} + +function checkDepth(hostId: AnyNodeId, scene: SceneApi): AttachResult { + // Count host's own depth (root = 0); attaching adds 1 to the child's depth. + let depth = 0 + let cursor: AnyNode | undefined = scene.get(hostId) + while (cursor?.parentId) { + cursor = scene.get(cursor.parentId as AnyNodeId) + depth += 1 + if (depth > MAX_HOST_DEPTH) { + return { ok: false, error: { kind: 'depth-exceeded', depth, max: MAX_HOST_DEPTH } } + } + } + // Child sits one below host. + if (depth + 1 > MAX_HOST_DEPTH) { + return { ok: false, error: { kind: 'depth-exceeded', depth: depth + 1, max: MAX_HOST_DEPTH } } + } + return { ok: true } +} + +/** + * Returns the surfaces declared by a host's NodeDefinition. Surfaces describe + * where other nodes can stack/mount — the `top` of a slab, the `sides` of a + * wall, or a custom callback. Returns null when the host's def declares no + * surfaces (or no def is registered). + */ +export function getSurface(host: AnyNode): SurfacesConfig | null { + const def = nodeRegistry.get(host.type) + return def?.capabilities.surfaces ?? null +} + +/** + * Resolves the stackable top height of a host (e.g. table surface, slab top, + * stair landing). Returns `null` when the host has no `surfaces.top`. + */ +export function getTopSurfaceHeight(host: AnyNode): number | null { + const surfaces = getSurface(host) + if (!surfaces?.top) return null + const { height } = surfaces.top + return typeof height === 'function' ? height(host) : height +} + +/** + * Whether `host` can receive a surface-resting (top-stacked) child. A + * ceiling-mounted item hangs from the ceiling, so its visible "top" is not a + * usable resting surface — nothing should stack on a ceiling fan. The check + * reads the instance-level `asset.attachTo` (not the host KIND, which is shared + * across all items) so a single gate covers every interaction path. + */ +export function canHostOnTop(host: AnyNode): boolean { + const attachTo = (host as { asset?: { attachTo?: string } }).asset?.attachTo + return attachTo !== 'ceiling' +} + +/** + * Pure host-discovery helper. Given a list of candidate hosts (already + * narrowed by spatial query) and a point, returns the first whose + * `capabilities.hostable` lists `placedKind` AND whose surface contains the + * point. The runtime is responsible for providing pre-filtered candidates; + * this function does not perform spatial queries itself. + */ +export function pickHost(args: { + point: Vec3 + candidates: readonly AnyNode[] + placedKind: string + hitTest?: (host: AnyNode, point: Vec3) => boolean +}): AnyNode | null { + for (const host of args.candidates) { + const def = nodeRegistry.get(host.type) + const hostable = def?.capabilities.hostable + if (!hostable) continue + if (!canHostOnTop(host)) continue + if (args.hitTest && !args.hitTest(host, args.point)) continue + return host + } + return null +} + +/** + * Convenience: clamps a Y coordinate to the top of a host surface, when one + * is declared. Returns the original Y if the host has no top surface. + */ +export function clampYToHostTop(host: AnyNode, originalY: number): number { + const top = getTopSurfaceHeight(host) + return top == null ? originalY : top +} diff --git a/services/pascal-editor/packages/core/src/services/index.ts b/services/pascal-editor/packages/core/src/services/index.ts new file mode 100644 index 000000000..85517d5ca --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/index.ts @@ -0,0 +1,114 @@ +export { + type AlignmentAnchor, + type AlignmentGuide, + type AlignmentGuideAxis, + type AnchorKind, + type BuildingPose, + bboxAnchors, + bboxCornerAnchors, + type ResolveAlignmentInBuildingResult, + type ResolveAlignmentInput, + type ResolveAlignmentResult, + resolveAlignment, + resolveAlignmentInBuildingWorld, +} from './alignment' +export { + collectAlignmentAnchors, + type FootprintAABB, + footprintAABB, + footprintAABBAt, + footprintAABBFrom, + movingAlignmentAnchors, + movingFootprintAnchors, + nodeAlignmentAnchors, + polygonAnchors, + wallSegmentAnchors, +} from './alignment-anchors' +export { + createDragSession, + type DragSession, + type DragSessionInput, + type DragSessionOptions, +} from './drag-session' +export { + type AttachError, + type AttachResult, + canAttach, + canHostOnTop, + clampYToHostTop, + getSurface, + getTopSurfaceHeight, + MAX_HOST_DEPTH, + pickHost, + type Vec3, +} from './hosting' +export { + DEFAULT_LEVEL_HEIGHT, + getCeilingAt, + getCeilingHeightAt, + getLevelHeight, +} from './level-height' +export { + type AxisLock, + applyAxisLock, + isMovable, + movePlanToward, + moveToward, + resolveMovable, +} from './movement' +export { + type AlongWallAlignment, + type AlongWallFeature, + computeEdgeGaps, + computeOpeningGuides, + DEFAULT_OPENING_GUIDE_TOLERANCES, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type EdgeGap, + type EqualSpacingRun, + type OpeningGuideInput, + type OpeningGuides, + type OpeningGuideTolerances, + type OpeningSpan, + type SillHeadGuide, + type VerticalAlignment, + type VerticalFeature, + type WallExtent, +} from './opening-guides' +export { + analyzePortConnectivity, + type PortConnection, + type PortConnectivity, + resolveConnectivityUpdates, +} from './port-connectivity' +export { + buildRiserDiagram, + projectIso, + type RiserDiagram, + type RiserLine, + type RiserMarker, +} from './riser-diagram' +export { + DEFAULT_ANGLE_STEP, + DEFAULT_GRID_STEP, + type SnapServices, + snapAngleToList, + snapPointAlongAngleRay, + snapPointToAngle, + snapPointToGrid, + snapScalar, + snapServices, + snapVec3ToGrid, + snapWorldXZToBuildingLocal, +} from './snap' +export { + buildPortComponents, + type SystemSummary, + summarizeSystemFor, +} from './system-graph' +export { + type DwvFinding, + type DwvSeverity, + validateDwv, +} from './validate-dwv' diff --git a/services/pascal-editor/packages/core/src/services/level-height.ts b/services/pascal-editor/packages/core/src/services/level-height.ts new file mode 100644 index 000000000..016711ac4 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/level-height.ts @@ -0,0 +1,86 @@ +import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager' +import type { CeilingNode, LevelNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' + +export const DEFAULT_LEVEL_HEIGHT = 2.5 + +/** + * Optional resolver for a wall's rendered base Y (mesh elevation). + * + * `packages/core` is pure domain logic and must not read viewer/Three.js + * state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have + * registry access (viewer systems, node tools) may pass a resolver so the + * mesh elevation is factored in; pure/headless callers (MCP, tests, server) + * omit it and get a deterministic result from serialized node data alone. + */ +export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined + +export function getLevelHeight( + levelId: string, + nodes: Record, + resolveWallBaseY?: WallBaseYResolver, +): number { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return DEFAULT_LEVEL_HEIGHT + + let maxTop = 0 + + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (!child) continue + if (child.type === 'ceiling') { + const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT + if (ch > maxTop) maxTop = ch + } else if (child.type === 'wall') { + let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0 + if (baseY < 0) baseY = 0 + const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT) + if (top > maxTop) maxTop = top + } + } + + return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT +} + +/** + * The ceiling covering level-local point `[x, z]`, or `null` when none + * sits over it. Points inside a ceiling's hole are treated as uncovered. + * When ceilings overlap, the lowest one wins — that's the surface a duct + * would actually hang from. + */ +export function getCeilingAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): CeilingNode | null { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return null + + let best: CeilingNode | null = null + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'ceiling') continue + const ceiling = child as CeilingNode + if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue + if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue + const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT + if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling + } + return best +} + +/** + * Underside elevation (meters above the level floor) of the ceiling + * covering level-local point `[x, z]`, or `null` when no ceiling sits + * over that point. See {@link getCeilingAt}. + */ +export function getCeilingHeightAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const ceiling = getCeilingAt(levelId, nodes, x, z) + return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null +} diff --git a/services/pascal-editor/packages/core/src/services/movement.test.ts b/services/pascal-editor/packages/core/src/services/movement.test.ts new file mode 100644 index 000000000..53cf4cf3a --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/movement.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../registry/registry' +import type { AnyNodeDefinition, Capabilities, MovableConfig } from '../registry/types' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { applyAxisLock, isMovable, movePlanToward, moveToward, resolveMovable } from './movement' + +const id = (s: string) => s as AnyNodeId + +function makeDef(kind: string, capabilities: Capabilities = {}): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as any, + category: 'utility', + defaults: () => ({}) as any, + capabilities, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } +} + +function makeNode(kind: string, idStr: string): AnyNode { + return { + id: id(idStr), + type: kind, + parentId: null, + visible: true, + } as unknown as AnyNode +} + +describe('resolveMovable', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('returns null when no def is registered', () => { + expect(resolveMovable(makeNode('mystery', 'm'))).toBeNull() + }) + + test('returns null when def declares no movable capability', () => { + registerNode(makeDef('static')) + expect(resolveMovable(makeNode('static', 's'))).toBeNull() + }) + + test('returns the declared config', () => { + registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } })) + const config = resolveMovable(makeNode('column', 'c')) + expect(config?.axes).toEqual(['x', 'z']) + expect(config?.gridSnap).toBe(true) + }) + + test('runs override callback when present', () => { + let overrideRan = false + const config: MovableConfig = { + axes: ['x'], + override: () => { + overrideRan = true + return { axes: ['y'] } + }, + } + registerNode(makeDef('weird', { movable: config })) + const resolved = resolveMovable(makeNode('weird', 'w')) + expect(overrideRan).toBe(true) + expect(resolved?.axes).toEqual(['y']) + }) + + test('override returning null falls back to base config', () => { + registerNode( + makeDef('column', { + movable: { axes: ['x', 'z'], override: () => null }, + }), + ) + const resolved = resolveMovable(makeNode('column', 'c')) + expect(resolved?.axes).toEqual(['x', 'z']) + }) +}) + +describe('applyAxisLock', () => { + test('passes through unlocked axes only', () => { + expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x'])).toEqual([10, 2, 3]) + expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x', 'z'])).toEqual([10, 2, 30]) + expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x', 'y', 'z'])).toEqual([10, 20, 30]) + }) + + test('empty lock returns current unchanged', () => { + expect(applyAxisLock([1, 2, 3], [10, 20, 30], [])).toEqual([1, 2, 3]) + }) +}) + +describe('moveToward', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('returns null when node is not movable', () => { + registerNode(makeDef('static')) + expect(moveToward(makeNode('static', 's'), [0, 0, 0], [1, 1, 1])).toBeNull() + }) + + test('axis-locks and returns the constrained target', () => { + registerNode(makeDef('column', { movable: { axes: ['x', 'z'] } })) + expect(moveToward(makeNode('column', 'c'), [0, 0.5, 0], [1, 99, 2])).toEqual([1, 0.5, 2]) + }) + + test('applies grid snap when capability declares gridSnap: true', () => { + registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } })) + const result = moveToward(makeNode('column', 'c'), [0, 0, 0], [0.3, 0, 0.6], { + gridStep: 0.25, + }) + expect(result).toEqual([0.25, 0, 0.5]) + }) + + test('grid snap can be overridden at call site', () => { + registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } })) + // Caller explicitly disables grid snap for this call + const result = moveToward(makeNode('column', 'c'), [0, 0, 0], [0.3, 0, 0.6], { + gridSnap: false, + }) + expect(result).toEqual([0.3, 0, 0.6]) + }) +}) + +describe('movePlanToward', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('returns 2D point with X/Z constrained, Y dropped', () => { + registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } })) + const result = movePlanToward( + makeNode('column', 'c'), + 0.5, // currentY + [0, 0], + [0.3, 0.6], + { gridStep: 0.25 }, + ) + expect(result).toEqual([0.25, 0.5]) + }) + + test('returns null when node is not movable', () => { + registerNode(makeDef('static')) + expect(movePlanToward(makeNode('static', 's'), 0, [0, 0], [1, 1])).toBeNull() + }) +}) + +describe('isMovable', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('true when movable.axes has entries', () => { + registerNode(makeDef('column', { movable: { axes: ['x'] } })) + expect(isMovable(makeNode('column', 'c'))).toBe(true) + }) + + test('false when no movable capability', () => { + registerNode(makeDef('static')) + expect(isMovable(makeNode('static', 's'))).toBe(false) + }) + + test('false when movable.axes is empty', () => { + registerNode(makeDef('locked', { movable: { axes: [] } })) + expect(isMovable(makeNode('locked', 'l'))).toBe(false) + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/movement.ts b/services/pascal-editor/packages/core/src/services/movement.ts new file mode 100644 index 000000000..3e8e38a26 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/movement.ts @@ -0,0 +1,99 @@ +import { nodeRegistry } from '../registry/registry' +import type { MovableConfig } from '../registry/types' +import type { AnyNode } from '../schema/types' +import { snapVec3ToGrid, type Vec3 } from './snap' + +/** + * Pure movement constraint helpers. Given a node and a target position, apply + * the constraints declared in `def.capabilities.movable` (axis lock, grid + * snap, override callback) and return the constrained target. + * + * No scene access, no React, no Three.js — caller passes the node, this + * returns the math result. + */ + +export type AxisLock = ReadonlyArray<'x' | 'y' | 'z'> + +/** + * Returns the MovableConfig effective for `node` after running its `override` + * callback if declared. Returns `null` if the node's def doesn't declare + * `movable` (i.e. the node is not movable). + */ +export function resolveMovable(node: AnyNode): MovableConfig | null { + const def = nodeRegistry.get(node.type) + const base = def?.capabilities.movable + if (!base) return null + if (base.override) { + const overridden = base.override({ node }) + return overridden ?? base + } + return base +} + +/** + * Projects a target X/Y/Z onto the axes a node is allowed to move on. Components + * outside the lock fall back to the node's current values, so caller-supplied + * positions can come from any 3D source without breaking axis-locked motion. + */ +export function applyAxisLock(current: Vec3, target: Vec3, axes: AxisLock): Vec3 { + return [ + axes.includes('x') ? target[0] : current[0], + axes.includes('y') ? target[1] : current[1], + axes.includes('z') ? target[2] : current[2], + ] +} + +/** + * Top-level helper: takes a node and a desired position, returns the position + * filtered through the node's movable capability (axis lock + optional grid + * snap). Returns `null` when the node is not movable. + */ +export function moveToward( + node: AnyNode, + current: Vec3, + target: Vec3, + options: { gridStep?: number; gridSnap?: boolean } = {}, +): Vec3 | null { + const config = resolveMovable(node) + if (!config) return null + + let next = applyAxisLock(current, target, config.axes) + + const wantsGridSnap = options.gridSnap ?? config.gridSnap + if (wantsGridSnap) { + next = snapVec3ToGrid(next, options.gridStep) + } + + return next +} + +/** + * 2D convenience: same as moveToward but for plan-view (X/Z) operations like + * floor placement. Returns a tuple in the X/Z plane so callers don't have to + * pack/unpack the dropped Y. + */ +export function movePlanToward( + node: AnyNode, + currentY: number, + current: readonly [number, number], + target: readonly [number, number], + options: { gridStep?: number; gridSnap?: boolean } = {}, +): readonly [number, number] | null { + const result = moveToward( + node, + [current[0], currentY, current[1]], + [target[0], currentY, target[1]], + options, + ) + if (!result) return null + return [result[0], result[2]] +} + +/** + * Returns true when a node's def declares it as movable on any axis. + * Quick predicate for tools/UI that gate on movability. + */ +export function isMovable(node: AnyNode): boolean { + const config = resolveMovable(node) + return config != null && config.axes.length > 0 +} diff --git a/services/pascal-editor/packages/core/src/services/opening-guides.test.ts b/services/pascal-editor/packages/core/src/services/opening-guides.test.ts new file mode 100644 index 000000000..648800b42 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/opening-guides.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from 'bun:test' +import { + computeEdgeGaps, + computeOpeningGuides, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type OpeningSpan, + type WallExtent, +} from './opening-guides' + +function span(id: string, centerS: number, width: number, centerY = 1, height = 1): OpeningSpan { + return { id, centerS, width, centerY, height } +} + +const WALL: WallExtent = { length: 10, height: 2.5 } + +describe('detectEqualSpacing', () => { + test('returns null for fewer than three openings', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + expect(detectEqualSpacing([a, b], 'b', 0.03, 0.02)).toBeNull() + }) + + test('detects a run of equal gaps across three openings', () => { + // width 1 each: a[0,1] b[2,3] c[4,5] → two gaps of 1m. + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) + const run = detectEqualSpacing([a, b, c], 'b', 0.03, 0.02) + expect(run).not.toBeNull() + expect(run?.gap).toBeCloseTo(1) + expect(run?.segments).toHaveLength(2) + expect(run?.openingIds).toEqual(['a', 'b', 'c']) + expect(run?.segments[0]).toEqual({ fromS: 1, toS: 2 }) + expect(run?.segments[1]).toEqual({ fromS: 3, toS: 4 }) + }) + + test('extends a run across four openings (three gaps)', () => { + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.5, 1), span('d', 6.5, 1)] + const run = detectEqualSpacing(openings, 'c', 0.03, 0.02) + expect(run?.segments).toHaveLength(3) + expect(run?.openingIds).toEqual(['a', 'b', 'c', 'd']) + }) + + test('returns null when gaps differ beyond tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1 + const c = span('c', 5, 1) // [4.5,5.5] → gap 1.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('returns null when the moving opening is not part of the equal run', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) // a,b,c form equal gaps of 1 + const d = span('d', 10, 1) // far right, breaks the run + expect(detectEqualSpacing([a, b, c, d], 'd', 0.03, 0.02)).toBeNull() + }) + + test('a near-zero (touching) gap breaks a run', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 1.505, 1) // [1.005,2.005] → gap 0.005 < minGap + const c = span('c', 3.005, 1) // [2.505,3.505] → gap 0.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('honours the equal-spacing tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1.0 + const c = span('c', 4.52, 1) // [4.02,5.02] → gap 1.02 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)?.segments).toHaveLength(2) + expect(detectEqualSpacing([a, b, c], 'b', 0.01, 0.02)).toBeNull() + }) +}) + +describe('computeEdgeGaps', () => { + test('measures clearance to the nearest neighbour on each side', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const left = span('l', 2, 1) // [1.5,2.5] + const right = span('r', 8, 1) // [7.5,8.5] + const gaps = computeEdgeGaps(moving, [left, right], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.distance).toBeCloseTo(2) + expect(byside.left?.target).toBe('opening') + expect(byside.left?.targetId).toBe('l') + expect(byside.right?.distance).toBeCloseTo(2) + expect(byside.right?.targetId).toBe('r') + }) + + test('falls back to wall ends with no neighbour', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const gaps = computeEdgeGaps(moving, [], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.target).toBe('wall-start') + expect(byside.left?.distance).toBeCloseTo(4.5) + expect(byside.right?.target).toBe('wall-end') + expect(byside.right?.distance).toBeCloseTo(4.5) + }) + + test('omits a side that is flush / overlapping (below minGap)', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const flush = span('l', 4, 1) // [3.5,4.5] right edge touches moving left + const gaps = computeEdgeGaps(moving, [flush], WALL, 0.02) + expect(gaps.find((g) => g.side === 'left')).toBeUndefined() + expect(gaps.find((g) => g.side === 'right')?.target).toBe('wall-end') + }) +}) + +describe('detectAlongWallAlignment', () => { + test('detects edge-to-edge alignment within tolerance', () => { + const moving = span('m', 5, 2) // [4,6] + const sib = span('s', 7.05, 2) // left edge 6.05 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('right') + expect(a?.targetFeature).toBe('left') + expect(a?.snap).toBeCloseTo(0.05) + expect(a?.s).toBeCloseTo(6.05) + }) + + test('detects centre alignment', () => { + const moving = span('m', 5, 2) + const sib = span('s', 5.03, 0.5) // centre 5.03, edges far from moving edges + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.targetFeature).toBe('center') + expect(a?.snap).toBeCloseTo(0.03) + }) + + test('returns null when nothing is within tolerance', () => { + const moving = span('m', 5, 2) + const sib = span('s', 9, 2) + expect(detectAlongWallAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('detectVerticalAlignment', () => { + test('detects a shared sill within tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1.0 + const sib = span('s', 8, 1, 2.04, 2) // sill 1.04 + const a = detectVerticalAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('sill') + expect(a?.targetFeature).toBe('sill') + expect(a?.snap).toBeCloseTo(0.04) + expect(a?.y).toBeCloseTo(1.04) + }) + + test('returns null when sills/tops differ beyond tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1, top 2, centre 1.5 + const sib = span('s', 8, 1, 0.4, 0.4) // sill 0.2, top 0.6, centre 0.4 + expect(detectVerticalAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('computeOpeningGuides', () => { + test('includes sill/head for windows', () => { + const moving = span('m', 5, 1, 1.5, 1) // bottom 1, top 2 + const guides = computeOpeningGuides({ + moving, + siblings: [], + wall: WALL, + includeVertical: true, + }) + expect(guides.sillHead?.sill).toBeCloseTo(1) + expect(guides.sillHead?.head).toBeCloseTo(0.5) // 2.5 - 2 + expect(guides.sillHead?.bottomY).toBeCloseTo(1) + expect(guides.sillHead?.topY).toBeCloseTo(2) + }) + + test('omits vertical guides for doors (sit on the floor)', () => { + const moving = span('m', 5, 1, 1, 2) + const sib = span('s', 8, 1, 1, 2) + const guides = computeOpeningGuides({ + moving, + siblings: [sib], + wall: WALL, + includeVertical: false, + }) + expect(guides.sillHead).toBeNull() + expect(guides.vertical).toBeNull() + // along-wall + proximity still computed for doors + expect(guides.gaps.length).toBeGreaterThan(0) + }) + + test('combines proximity and equal-spacing in one pass', () => { + const moving = span('b', 2.5, 1) + const guides = computeOpeningGuides({ + moving, + siblings: [span('a', 0.5, 1), span('c', 4.5, 1)], + wall: WALL, + includeVertical: true, + }) + expect(guides.gaps).toHaveLength(2) + expect(guides.equalSpacing?.gap).toBeCloseTo(1) + expect(guides.equalSpacing?.openingIds).toEqual(['a', 'b', 'c']) + }) +}) + +describe('opening-guides — review regressions', () => { + test('detectEqualSpacing finds a run that starts partway through a drifting sequence', () => { + // gaps 1.00, 1.02, 1.04 — only [b,c,d] is equal within 0.03 and includes the + // moving opening; a first-gap-anchored greedy scan used to drop it. + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.52, 1), span('d', 6.56, 1)] + const run = detectEqualSpacing(openings, 'd', 0.03, 0.02) + expect(run?.openingIds).toEqual(['b', 'c', 'd']) + expect(run?.gap).toBeCloseTo(1.03) + expect(run?.segments).toHaveLength(2) + }) + + test('detectEqualSpacing prefers the leftmost run on a length tie', () => { + // gaps 1,1,2,2 with the moving opening in the middle — two equal-length runs. + const openings = [ + span('a', 0.5, 1), + span('b', 2.5, 1), + span('c', 4.5, 1), + span('d', 7.5, 1), + span('e', 10.5, 1), + ] + expect(detectEqualSpacing(openings, 'c', 0.03, 0.02)?.openingIds).toEqual(['a', 'b', 'c']) + }) + + test('computeEdgeGaps suppresses both sides when a sibling overlaps', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const containing = span('s', 5, 2) // [4,6] straddles both edges + expect(computeEdgeGaps(moving, [containing], WALL, 0.02)).toEqual([]) + }) + + test('alignment detectors ignore the moving opening if present in siblings', () => { + const moving = span('m', 5, 2, 1.5, 1) + expect(detectAlongWallAlignment(moving, [moving], 0.08)).toBeNull() + expect(detectVerticalAlignment(moving, [moving], 0.08)).toBeNull() + }) + + test('detectAlongWallAlignment reports a negative snap when the feature is past the target', () => { + const moving = span('m', 5, 2) // centre 5 + const sib = span('s', 4.96, 0.5) // centre 4.96 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.snap).toBeCloseTo(-0.04) + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/opening-guides.ts b/services/pascal-editor/packages/core/src/services/opening-guides.ts new file mode 100644 index 000000000..5f0da29ed --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/opening-guides.ts @@ -0,0 +1,404 @@ +// Proximity / alignment guides for wall-hosted openings (doors, windows). +// +// Pure geometry over a single host wall's LOCAL frame — no Three.js, no scene +// store, no React — so it runs identically for the 3D viewport and the 2D +// floor plan and is unit-testable in isolation. Callers extract the spans from +// the scene graph (an opening's `position[0]` is its along-wall centre, its +// `position[1]` its vertical centre with the wall base at y=0) and feed them in; +// the renderers transform the returned wall-local coordinates back to world +// (3D) or plan (2D). +// +// What it produces, mirroring the affordances architects expect (and Figma's +// smart guides): +// - sill/head : a window's bottom edge → floor and top edge → wall top. +// - edge gaps : along-wall clearance to the nearest neighbour opening (or +// the wall end) on each side. +// - alongWall : the moving opening's edge/centre lining up with a +// neighbour's edge/centre along the wall. +// - vertical : two openings sharing a sill / head / vertical centre. +// - equalSpacing : a run of 3+ openings with (near-)equal gaps between them. +// +// Detection is passive — it reports what currently coincides within tolerance +// and the snap delta that would make it exact, leaving the snap decision to the +// caller's manipulation policy (grid vs. alignment vs. Shift bypass). + +/** An opening's footprint in its host wall's local frame. */ +export type OpeningSpan = { + id: string + /** Centre along the wall, measured from `wall.start` (m). */ + centerS: number + /** Along-wall extent (m). */ + width: number + /** Vertical centre above the wall base (floor at y=0) (m). */ + centerY: number + /** Vertical extent (m). */ + height: number +} + +export type WallExtent = { + /** Wall length (m). */ + length: number + /** Wall height (m). */ + height: number +} + +export type OpeningGuideTolerances = { + /** Max distance for an edge/centre to count as aligned with a neighbour (m). */ + align: number + /** Max difference between two gaps for them to count as equal (m). */ + equalSpacing: number + /** Gaps below this are treated as touching/overlap noise and ignored (m). */ + minGap: number +} + +export const DEFAULT_OPENING_GUIDE_TOLERANCES: OpeningGuideTolerances = { + // Parity with the along-wall snap threshold (`ALONG_WALL_ALIGN_THRESHOLD_M`). + align: 0.08, + equalSpacing: 0.03, + minGap: 0.02, +} + +/** Which along-wall feature of an opening a guide references. */ +export type AlongWallFeature = 'left' | 'center' | 'right' +/** Which vertical feature of an opening a guide references. */ +export type VerticalFeature = 'sill' | 'center' | 'top' + +export type SillHeadGuide = { + /** Floor (y=0) → the opening's bottom edge (m). */ + sill: number + /** Wall-local y of the bottom edge. */ + bottomY: number + /** The opening's top edge → the wall top (m). */ + head: number + /** Wall-local y of the top edge. */ + topY: number +} + +export type EdgeGap = { + side: 'left' | 'right' + /** Clearance along the wall (m). */ + distance: number + /** Wall-local s of the moving opening's edge. */ + fromS: number + /** Wall-local s of the neighbour edge / wall end. */ + toS: number + target: 'opening' | 'wall-start' | 'wall-end' + /** Set when `target === 'opening'`. */ + targetId?: string +} + +export type AlongWallAlignment = { + /** Wall-local s the two features share. */ + s: number + movingFeature: AlongWallFeature + targetId: string + targetFeature: AlongWallFeature + /** Delta to add to the moving opening's `centerS` to make them coincide. */ + snap: number +} + +export type VerticalAlignment = { + /** Wall-local y the two features share. */ + y: number + movingFeature: VerticalFeature + targetId: string + targetFeature: VerticalFeature + /** Delta to add to the moving opening's `centerY` to make them coincide. */ + snap: number +} + +export type EqualSpacingRun = { + /** The repeated gap value (average of the run's gaps) (m). */ + gap: number + /** The equal-gap segments along the wall, in order (left → right). */ + segments: { fromS: number; toS: number }[] + /** Participating opening ids, ordered along the wall, including the moving one. */ + openingIds: string[] +} + +export type OpeningGuides = { + sillHead: SillHeadGuide | null + gaps: EdgeGap[] + alongWall: AlongWallAlignment | null + vertical: VerticalAlignment | null + equalSpacing: EqualSpacingRun | null +} + +export type OpeningGuideInput = { + moving: OpeningSpan + /** Other openings on the SAME wall (the moving opening excluded). */ + siblings: readonly OpeningSpan[] + wall: WallExtent + /** + * Whether to compute vertical (sill/head/vertical-alignment) guides. True for + * windows; false for doors, which sit on the floor so their sill is always 0. + */ + includeVertical: boolean + tolerances?: Partial +} + +const leftEdge = (s: OpeningSpan) => s.centerS - s.width / 2 +const rightEdge = (s: OpeningSpan) => s.centerS + s.width / 2 +const bottomEdge = (s: OpeningSpan) => s.centerY - s.height / 2 +const topEdge = (s: OpeningSpan) => s.centerY + s.height / 2 + +function alongWallFeatureCoord(s: OpeningSpan, feature: AlongWallFeature): number { + if (feature === 'left') return leftEdge(s) + if (feature === 'right') return rightEdge(s) + return s.centerS +} + +function verticalFeatureCoord(s: OpeningSpan, feature: VerticalFeature): number { + if (feature === 'sill') return bottomEdge(s) + if (feature === 'top') return topEdge(s) + return s.centerY +} + +const ALONG_WALL_FEATURES: AlongWallFeature[] = ['left', 'center', 'right'] +const VERTICAL_FEATURES: VerticalFeature[] = ['sill', 'center', 'top'] + +/** + * Edge-to-edge clearance from the moving opening to the nearest neighbour on + * each side, falling back to the wall ends when there is no neighbour — the + * "how much wall is left here" reading. Returns 0–2 gaps (one per side); a side + * is omitted when its clearance is below `minGap` (the opening is flush against + * or overlapping that neighbour). + */ +export function computeEdgeGaps( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + wall: WallExtent, + minGap: number, +): EdgeGap[] { + const movingLeft = leftEdge(moving) + const movingRight = rightEdge(moving) + + // A sibling that straddles one of the moving opening's edges is an OVERLAP, + // not a neighbour: there is no clearance on that side, and we must not fall + // back to the wall end (which would report a misleading distance measured + // "through" the overlapping opening). + const leftCrossed = siblings.some((s) => leftEdge(s) < movingLeft && rightEdge(s) > movingLeft) + const rightCrossed = siblings.some((s) => leftEdge(s) < movingRight && rightEdge(s) > movingRight) + + let leftNeighbour: { s: number; id: string } | null = null + let rightNeighbour: { s: number; id: string } | null = null + for (const sib of siblings) { + const sibRight = rightEdge(sib) + const sibLeft = leftEdge(sib) + // Entirely to the left of the moving opening → candidate left neighbour. + if (sibRight <= movingLeft && (leftNeighbour === null || sibRight > leftNeighbour.s)) { + leftNeighbour = { s: sibRight, id: sib.id } + } + // Entirely to the right → candidate right neighbour. + if (sibLeft >= movingRight && (rightNeighbour === null || sibLeft < rightNeighbour.s)) { + rightNeighbour = { s: sibLeft, id: sib.id } + } + } + + const gaps: EdgeGap[] = [] + + if (!leftCrossed) { + const leftToS = leftNeighbour ? leftNeighbour.s : 0 + const leftDistance = movingLeft - leftToS + if (leftDistance >= minGap) { + gaps.push({ + side: 'left', + distance: leftDistance, + fromS: movingLeft, + toS: leftToS, + target: leftNeighbour ? 'opening' : 'wall-start', + targetId: leftNeighbour?.id, + }) + } + } + + if (!rightCrossed) { + const rightToS = rightNeighbour ? rightNeighbour.s : wall.length + const rightDistance = rightToS - movingRight + if (rightDistance >= minGap) { + gaps.push({ + side: 'right', + distance: rightDistance, + fromS: movingRight, + toS: rightToS, + target: rightNeighbour ? 'opening' : 'wall-end', + targetId: rightNeighbour?.id, + }) + } + } + + return gaps +} + +/** + * The closest coincidence between any of the moving opening's edges/centre and + * any sibling's edges/centre along the wall, within `tolerance`. Edge-to-edge + * and centre-to-centre are weighed equally; the single closest pair wins + * (matching the one-guide-per-axis behaviour of the floor-plane resolver). + */ +export function detectAlongWallAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): AlongWallAlignment | null { + let best: AlongWallAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of ALONG_WALL_FEATURES) { + const movingCoord = alongWallFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of ALONG_WALL_FEATURES) { + const targetCoord = alongWallFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + s: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * The closest coincidence between the moving opening's sill/centre/top and any + * sibling's sill/centre/top, within `tolerance` — the "these two windows share + * a sill height" detector. Same single-best-match policy as the along-wall + * variant. + */ +export function detectVerticalAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): VerticalAlignment | null { + let best: VerticalAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of VERTICAL_FEATURES) { + const movingCoord = verticalFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of VERTICAL_FEATURES) { + const targetCoord = verticalFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + y: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * Figma-style equal-spacing detection: order all openings along the wall, look + * at the clearances BETWEEN consecutive openings, and return the longest run of + * ≥2 consecutive gaps that are equal within `tolerance` and that the moving + * opening participates in (so the badges only appear while the drag is actually + * forming or extending a series). Returns null when no such run exists. + * + * Gaps below `minGap` (touching/overlapping openings) break a run — a row of + * flush openings is not "equally spaced". + */ +export function detectEqualSpacing( + allOpenings: readonly OpeningSpan[], + movingId: string, + tolerance: number, + minGap: number, +): EqualSpacingRun | null { + if (allOpenings.length < 3) return null + const sorted = [...allOpenings].sort((a, b) => a.centerS - b.centerS) + const movingIndex = sorted.findIndex((s) => s.id === movingId) + if (movingIndex < 0) return null + + // Clearance between opening i and i+1. + const gaps: { value: number; fromS: number; toS: number }[] = [] + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i] + const b = sorted[i + 1] + if (!a || !b) continue + const fromS = rightEdge(a) + const toS = leftEdge(b) + gaps.push({ value: toS - fromS, fromS, toS }) + } + + // Longest contiguous window of gaps that are (a) each ≥ minGap and (b) + // mutually equal within tolerance (window max − min ≤ tolerance), spanning at + // least 2 gaps and including the moving opening. Brute force over windows + // (openings per wall are few). A first-gap-anchored greedy scan is NOT + // equivalent: it drops a valid run that begins partway through a drifting + // sequence — e.g. gaps 1.00, 1.02, 1.04 with the moving opening at the end, + // where [1.02, 1.04] is a real run. On a length tie the leftmost window wins, + // for determinism. + let best: EqualSpacingRun | null = null + for (let lo = 0; lo < gaps.length; lo++) { + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + for (let hi = lo; hi < gaps.length; hi++) { + const gap = gaps[hi] + if (!gap || gap.value < minGap) break // a sub-minGap gap can't join a run + min = Math.min(min, gap.value) + max = Math.max(max, gap.value) + if (max - min > tolerance) break // extending only widens the spread + const gapCount = hi - lo + 1 + if (gapCount < 2) continue + const firstOpening = lo // gap i sits between openings i and i+1 + const lastOpening = hi + 1 + if (movingIndex < firstOpening || movingIndex > lastOpening) continue + if (best !== null && gapCount <= best.segments.length) continue + const windowGaps = gaps.slice(lo, hi + 1) + best = { + gap: windowGaps.reduce((sum, g) => sum + g.value, 0) / windowGaps.length, + segments: windowGaps.map((g) => ({ fromS: g.fromS, toS: g.toS })), + openingIds: sorted.slice(firstOpening, lastOpening + 1).map((s) => s.id), + } + } + } + return best +} + +/** + * Compute every proximity/alignment guide for the moving opening in one pass. + * Pure: feed it the moving opening's wall-local span, its same-wall siblings, + * and the wall extent; render the result in whichever view. + */ +export function computeOpeningGuides(input: OpeningGuideInput): OpeningGuides { + const tol = { ...DEFAULT_OPENING_GUIDE_TOLERANCES, ...input.tolerances } + const { moving, siblings, wall, includeVertical } = input + + const sillHead: SillHeadGuide | null = includeVertical + ? { + sill: bottomEdge(moving), + bottomY: bottomEdge(moving), + head: wall.height - topEdge(moving), + topY: topEdge(moving), + } + : null + + return { + sillHead, + gaps: computeEdgeGaps(moving, siblings, wall, tol.minGap), + alongWall: detectAlongWallAlignment(moving, siblings, tol.align), + vertical: includeVertical ? detectVerticalAlignment(moving, siblings, tol.align) : null, + equalSpacing: detectEqualSpacing( + [moving, ...siblings], + moving.id, + tol.equalSpacing, + tol.minGap, + ), + } +} diff --git a/services/pascal-editor/packages/core/src/services/port-connectivity.test.ts b/services/pascal-editor/packages/core/src/services/port-connectivity.test.ts new file mode 100644 index 000000000..7d9cc1ea5 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/port-connectivity.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeDefinition, DistributionRole, NodePort } from '../registry' +import { registerNode } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' +import { analyzePortConnectivity, resolveConnectivityUpdates } from './port-connectivity' + +type Point = [number, number, number] + +// Stub registrations mirroring the real kinds' port + role conventions +// without importing the nodes package (which pulls in CSG and can't load +// under the test runner). A run exposes start/end at its path tips; the +// fitting here is a simple two-collar elbow at ±X around its position. +function stubDef( + kind: string, + distributionRole: DistributionRole, + ports: (node: AnyNode) => NodePort[], +): void { + registerNode({ + kind, + schemaVersion: 1, + schema: {}, + category: 'utility', + distributionRole, + defaults: () => ({}), + capabilities: {}, + ports, + } as unknown as AnyNodeDefinition) +} + +stubDef('duct-segment', 'run', (node) => { + const path = (node as unknown as { path: Point[] }).path + const system = (node as unknown as { system: string }).system + return [ + { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system }, + { id: 'end', position: path[path.length - 1]!, direction: [1, 0, 0], diameter: 6, system }, + ] +}) +stubDef('duct-fitting', 'fitting', (node) => { + const position = (node as unknown as { position: Point }).position + const system = (node as unknown as { system: string }).system + return [ + { + id: 'inlet', + position: [position[0] - 0.2, position[1], position[2]], + direction: [-1, 0, 0], + diameter: 6, + system, + }, + { + id: 'outlet', + position: [position[0] + 0.2, position[1], position[2]], + direction: [1, 0, 0], + diameter: 6, + system, + }, + ] +}) +stubDef('duct-tee', 'fitting', (node) => { + const position = (node as unknown as { position: Point }).position + const system = (node as unknown as { system: string }).system + return [ + { + id: 'inlet', + position: [position[0] - 0.2, position[1], position[2]], + direction: [-1, 0, 0], + diameter: 6, + system, + }, + { + id: 'outlet', + position: [position[0] + 0.2, position[1], position[2]], + direction: [1, 0, 0], + diameter: 6, + system, + }, + { + id: 'branch', + position: [position[0], position[1], position[2] + 0.2], + direction: [0, 0, 1], + diameter: 6, + system, + }, + ] +}) + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} + +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +function expectPointClose(actual: Point, expected: Point) { + expect(actual[0]).toBeCloseTo(expected[0], 6) + expect(actual[1]).toBeCloseTo(expected[1], 6) + expect(actual[2]).toBeCloseTo(expected[2], 6) +} + +describe('port connectivity — joint follow (stretch vs translate)', () => { + // Layout: duct A ends at the fitting's inlet (−0.2,0,0); duct B starts at the + // fitting's outlet (+0.2,0,0). Both runs lie on the X axis. Dragging A's + // mated endpoint carries the fitting and duct B; how B reacts depends on + // whether the drag is along its axis (stretch) or across it (translate). + function joint() { + const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) + const ductA = makeNode('duct-segment', { + path: [ + [-3, 0, 0], + [-0.2, 0, 0], + ], + system: 'supply', + }) + const ductB = makeNode('duct-segment', { + path: [ + [0.2, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + return { fitting, ductA, ductB } + } + + function movedA(end: Point): AnyNode { + const { ductA } = joint() + return { ...(ductA as Record), path: [[-3, 0, 0], end] } as AnyNode + } + + test('the fitting and sibling run are picked up as carried connections', () => { + const { fitting, ductA, ductB } = joint() + const connectivity = analyzePortConnectivity(ductA, sceneOf(fitting, ductA, ductB)) + expect( + connectivity.connections.find((c) => c.kind === 'rigid-node' && c.nodeId === fitting.id), + ).toBeDefined() + expect( + connectivity.connections.find((c) => c.kind === 'run' && c.nodeId === ductB.id), + ).toBeDefined() + }) + + test('perpendicular drag translates the WHOLE sibling run (no skew)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) + + // Move A's mated end +1 in Z — perpendicular to B's X axis. + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) + + expect( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + ).toEqual([0, 0, 1]) + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Both ends ride +1 in Z: the run keeps its length and direction. + expect(bPath[0]).toEqual([0.2, 0, 1]) + expect(bPath[1]).toEqual([3, 0, 1]) + }) + + test('parallel drag stretches the sibling run (only the near end slides)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) + + // Move A's mated end +0.5 in X — along B's axis (the fitting slides toward B). + const updates = resolveConnectivityUpdates(connectivity, movedA([0.3, 0, 0])) + + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Near end slid +0.5 in X; far end stayed put → the run shortened. + expect(bPath[0]).toEqual([0.7, 0, 0]) + expect(bPath[1]).toEqual([3, 0, 0]) + }) + + test('perpendicular slide propagates through the sibling run to its far joint', () => { + // Extend the chain: duct B's far end (3,0,0) meets a second elbow, and duct + // C hangs off that elbow. A perpendicular drag should carry the whole chain. + const { fitting, ductA, ductB } = joint() + const elbow2 = makeNode('duct-fitting', { position: [3.2, 0, 0], system: 'supply' }) + // elbow ports are ±0.2 on X around its position → inlet at (3,0,0) meets B. + const ductC = makeNode('duct-segment', { + path: [ + [3.4, 0, 0], + [6, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, elbow2, ductC) + const connectivity = analyzePortConnectivity(ductA, nodes) + + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) + + // Whole chain rode +1 in Z. + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + expect(bPath[1]).toEqual([3, 0, 1]) + expect((updates.find((u) => u.id === elbow2.id)!.data as { position: Point }).position).toEqual( + [3.2, 0, 1], + ) + const cPath = (updates.find((u) => u.id === ductC.id)!.data as { path: Point[] }).path + expect(cPath[0]).toEqual([3.4, 0, 1]) + expect(cPath[1]).toEqual([6, 0, 1]) + }) + + test('a run reached from both ends applies both endpoint deltas', () => { + const moved = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + const follower = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, follower) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [0, 0, 1], + [3, 0, 2], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path + expect(path[0]).toEqual([0, 0, 1]) + expect(path[1]).toEqual([3, 0, 2]) + }) + + test('a polyline run reached from both ends preserves interior bend shape', () => { + const moved = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 3], + ], + system: 'supply', + }) + const follower = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [1, 0, 0], + [1, 0, 3], + [3, 0, 3], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, follower) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.5, 0, 0], + [3.5, 0, 3], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path + expect(path).toEqual([ + [-0.5, 0, 0], + [1, 0, 0], + [1, 0, 3], + [3.5, 0, 3], + ]) + }) + + test('a fitting reached from both collars rebroadcasts its final compatible rigid delta', () => { + const moved = makeNode('duct-segment', { + path: [ + [-0.2, 0, 0], + [0.2, 0, 0], + ], + system: 'supply', + }) + const fitting = makeNode('duct-tee', { position: [0, 0, 0], system: 'supply' }) + const downstream = makeNode('duct-segment', { + path: [ + [0, 0, 0.2], + [3, 0, 0.2], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, fitting, downstream) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.2, 0, 1], + [0.2, 0, 1.00005], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + expectPointClose( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + [0, 0, 1.000025], + ) + const path = (updates.find((u) => u.id === downstream.id)!.data as { path: Point[] }).path + expectPointClose(path[0]!, [0, 0, 1.200025]) + expectPointClose(path[1]!, [3, 0, 1.200025]) + }) + + test('a fitting reached from incompatible collars merges constraints deterministically', () => { + const moved = makeNode('duct-segment', { + path: [ + [-0.2, 0, 0], + [0.2, 0, 0], + ], + system: 'supply', + }) + const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) + const nodes = sceneOf(moved, fitting) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.2, 0, 1], + [0.2, 0, -1], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + expectPointClose( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + [0, 0, 0], + ) + }) + + test('an unrelated run not on the fitting is left alone', () => { + const { fitting, ductA, ductB } = joint() + const distant = makeNode('duct-segment', { + path: [ + [10, 0, 0], + [13, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, distant) + const connectivity = analyzePortConnectivity(ductA, nodes) + expect(connectivity.connections.find((c) => c.nodeId === distant.id)).toBeUndefined() + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/port-connectivity.ts b/services/pascal-editor/packages/core/src/services/port-connectivity.ts new file mode 100644 index 000000000..57c0b0d45 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/port-connectivity.ts @@ -0,0 +1,445 @@ +import { nodeRegistry } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' + +/** + * Connectivity-aware editing for port-bearing distribution kinds + * (HVAC ductwork AND DWV plumbing). + * + * Two nodes are "connected" when a port of one coincides in space with a + * port of the other — exactly how the placement tools mate a fitting onto + * a duct end (they snap the fitting's collar onto the run's open port). + * This service reads that relationship back out so an edit to one node can + * carry its neighbours along. + * + * Pure logic: it asks each node for its ports via `def.ports` (level-local + * meters) and does arithmetic. No Three.js, no rendering — it lives in + * core and is consumed by the editor's move tool and the duct/pipe + * selection affordances alike. + * + * ## Propagation model + * + * The joint graph is snapshotted once at drag start (`analyzePortConnectivity`) + * and walked every frame (`resolveConnectivityUpdates`) given the moved node's + * live transform. Deltas flow outward from the moved node through coincident + * ports: + * + * - **Fitting** (rigid): a collar pushed by delta `d` translates the whole + * fitting by `d`; every other collar carries that same `d` onward. + * - **Run** (stretch + slide, never skew): an endpoint pushed by delta `d` is + * split against the run's own axis. The *parallel* part slides only that + * endpoint (the run lengthens / shortens); the *perpendicular* part + * translates the entire run (so its direction is preserved). The far + * endpoint therefore moves by just the perpendicular part, and that part + * propagates onward to whatever is mated to the far endpoint. + * + * Propagation walks the whole connected component so a joint stays welded all + * the way down the chain, with a visited guard so cycles (looped runs) and + * shared joints terminate. First-reached (shortest path) wins on a node + * reachable two ways. + */ + +type Point = readonly [number, number, number] + +/** Distance (meters) under which two ports count as the same joint. Joints + * formed by placement snapping coincide to sub-millimeter; 5 cm leaves + * generous slack for grid-snapped hand placement without false matches. */ +const COINCIDENT_EPS_M = 0.05 + +/** Below this (meters) a propagated delta is treated as zero — stops the + * walk from chasing sub-millimeter perpendicular residue. */ +const DELTA_EPS_M = 1e-4 +const PROPAGATION_EPS_M = 1e-9 + +/** A node carried by the edit, plus the snapshot needed to revert it. Kept + * deliberately small: the move tools read only `kind` + `nodeId` and the + * matching start snapshot to revert before the single tracked commit. */ +export type PortConnection = + | { + /** A fitting mated collar-to-collar: it translates rigidly. */ + kind: 'rigid-node' + nodeId: AnyNodeId + /** Node's `position` at edit-start. */ + startPosition: Point + } + | { + /** A run whose endpoint(s) ride the edit: it stretches and/or + * translates, never skews. */ + kind: 'run' + nodeId: AnyNodeId + /** The run's full `path` at edit-start. */ + startPath: Point[] + } + +/** One node in the snapshotted joint graph (everything reachable from the + * moved node, excluding the moved node itself). */ +type GraphNode = { + id: AnyNodeId + role: 'run' | 'fitting' + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + startPath?: Point[] + startPosition?: Point +} + +/** Who else sits on a given node's port, keyed `nodeId` → `portId` → mates. */ +type Adjacency = Record>> + +export type PortConnectivity = { + 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. */ + startMovedPorts: Record + /** Reachable run/fitting nodes (excludes the moved node), keyed by id. */ + graph: Record + /** Port coincidence edges across the moved node + every graph node. */ + adjacency: Adjacency + /** Flat list of carried nodes for the move tools' revert + "anything to + * follow?" check. Derived from `graph`. */ + connections: PortConnection[] +} + +function portsOf( + node: AnyNode, +): ReadonlyArray<{ id: string; position: Point; system?: string }> | undefined { + return nodeRegistry.get(node.type)?.ports?.(node) as + | ReadonlyArray<{ id: string; position: Point; system?: string }> + | undefined +} + +/** A node's distribution role from the registry (run / fitting / …). */ +function roleOf(node: AnyNode): string | undefined { + return nodeRegistry.get(node.type)?.distributionRole +} + +function distSq(a: Point, b: Point): number { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + const dz = a[2] - b[2] + return dx * dx + dy * dy + dz * dz +} + +/** Two ports mate when they coincide AND don't cross incompatible systems + * (a supply duct and a waste pipe that merely touch must not fuse). */ +function portsMate( + a: { position: Point; system?: string }, + b: { position: Point; system?: string }, + epsSq: number, +): boolean { + if (distSq(a.position, b.position) > epsSq) return false + if (a.system && b.system && a.system !== b.system) return false + return true +} + +/** + * Snapshot the joint graph reachable from `movedNode`'s ports, taken at the + * start of a move/resize. Call once before the drag; feed the result to + * `resolveConnectivityUpdates` on every frame. + * + * Only `run`-role partners (segments) and `fitting`-role partners are walked — + * terminals and equipment usually mount to a surface and shouldn't be yanked + * off it when an adjacent fitting nudges. Fittings that declare + * `portConnectivityFollow: false` are anchored fixtures (e.g. pipe-trap) and + * are skipped, so a connected run stretches against them instead. + */ +export function analyzePortConnectivity( + movedNode: AnyNode, + nodes: Record, +): PortConnectivity { + const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + + const movedPorts = portsOf(movedNode) ?? [] + const startMovedPorts: Record = {} + for (const p of movedPorts) startMovedPorts[p.id] = p.position + + // Candidate partners: every run + every following fitting in the scene. + const candidates: GraphNode[] = [] + for (const other of Object.values(nodes)) { + if (!other || other.id === movedNode.id) continue + const role = roleOf(other) + if (role !== 'run' && role !== 'fitting') continue + if (role === 'fitting' && nodeRegistry.get(other.type)?.portConnectivityFollow === false) { + continue + } + const ports = portsOf(other) + if (!ports) continue + const startPath = + role === 'run' + ? (other as unknown as { path?: Point[] }).path?.map((p) => [...p] as Point) + : undefined + if (role === 'run' && (!startPath || startPath.length < 2)) continue + const startPosition = + role === 'fitting' + ? (() => { + const pos = (other as unknown as { position?: Point }).position + return pos ? ([pos[0], pos[1], pos[2]] as Point) : undefined + })() + : undefined + if (role === 'fitting' && !startPosition) continue + candidates.push({ id: other.id as AnyNodeId, role, ports, startPath, startPosition }) + } + + // Walk outward from the moved node, collecting every node reachable through + // coincident ports. The adjacency records each port's mates so the resolver + // can replay the same edges with live deltas. + const adjacency: Adjacency = {} + const addEdge = (nodeId: string, portId: string, mate: { nodeId: AnyNodeId; portId: string }) => { + const byPort = adjacency[nodeId] ?? {} + adjacency[nodeId] = byPort + const mates = byPort[portId] ?? [] + byPort[portId] = mates + mates.push(mate) + } + + const graph: Record = {} + const visited = new Set([movedNode.id]) + + // Seed: the moved node's own ports. + const queue: Array<{ + id: string + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + }> = [{ id: movedNode.id, ports: movedPorts }] + + while (queue.length > 0) { + const { id, ports } = queue.shift()! + for (const port of ports) { + for (const cand of candidates) { + if (cand.id === id) continue + for (const cp of cand.ports) { + if (!portsMate(port, cp, epsSq)) continue + addEdge(id, port.id, { nodeId: cand.id, portId: cp.id }) + addEdge(cand.id, cp.id, { nodeId: id as AnyNodeId, portId: port.id }) + if (!visited.has(cand.id)) { + visited.add(cand.id) + graph[cand.id] = cand + queue.push({ id: cand.id, ports: cand.ports }) + } + } + } + } + } + + const connections: PortConnection[] = Object.values(graph).map((g) => + g.role === 'fitting' + ? { kind: 'rigid-node', nodeId: g.id, startPosition: g.startPosition! } + : { kind: 'run', nodeId: g.id, startPath: g.startPath! }, + ) + + return { + movedNodeId: movedNode.id as AnyNodeId, + startMovedPorts, + graph, + adjacency, + connections, + } +} + +function add(a: Point, b: Point): Point { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +function sub(a: Point, b: Point): Point { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +function lenSq(v: Point): number { + return v[0] * v[0] + v[1] * v[1] + v[2] * v[2] +} + +/** Split `delta` into the component along unit `axis` and the remainder. */ +function decompose(delta: Point, axis: Point): { parallel: Point; perp: Point } { + const dot = delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2] + const parallel: Point = [axis[0] * dot, axis[1] * dot, axis[2] * dot] + return { parallel, perp: sub(delta, parallel) } +} + +function scale(v: Point, scalar: number): Point { + return [v[0] * scalar, v[1] * scalar, v[2] * scalar] +} + +function average(deltas: Point[]): Point { + const sum = deltas.reduce((acc, delta) => add(acc, delta), [0, 0, 0]) + return scale(sum, 1 / deltas.length) +} + +function nearlyEqual(a: Point, b: Point): boolean { + return lenSq(sub(a, b)) <= DELTA_EPS_M * DELTA_EPS_M +} + +function propagationEqual(a: Point, b: Point): boolean { + return lenSq(sub(a, b)) <= PROPAGATION_EPS_M * PROPAGATION_EPS_M +} + +function effectivePortDeltas( + constraints: Record>, +): Record { + return Object.fromEntries( + Object.entries(constraints).map(([portId, bySource]) => [ + portId, + average(Object.values(bySource)), + ]), + ) +} + +/** Unit direction of the run's segment adjacent to its `start` / `end` tip. */ +function endpointAxis(path: Point[], portId: string): Point { + const n = path.length + const [a, b] = portId === 'start' ? [path[1]!, path[0]!] : [path[n - 2]!, path[n - 1]!] + const dir = sub(b, a) + const l2 = lenSq(dir) + if (l2 < 1e-12) return [0, 0, 0] + const l = Math.sqrt(l2) + return [dir[0] / l, dir[1] / l, dir[2] / l] +} + +function runPathFromSinglePortDelta( + startPath: Point[], + portId: 'start' | 'end', + delta: Point, +): Point[] { + const nearIdx = portId === 'start' ? 0 : startPath.length - 1 + const axis = endpointAxis(startPath, portId) + const { parallel, perp } = decompose(delta, axis) + const path = startPath.map((p) => add(p, perp)) + path[nearIdx] = add(path[nearIdx]!, parallel) + return path +} + +function runEndpointDeltas(startPath: Point[], path: Point[]): Record { + return { + start: sub(path[0]!, startPath[0]!), + end: sub(path[path.length - 1]!, startPath[startPath.length - 1]!), + } +} + +function runPathFromPortDeltas(startPath: Point[], portDeltas: Record): Point[] { + const startDelta = portDeltas.start + const endDelta = portDeltas.end + if (startDelta && endDelta) { + if (startPath.length === 2) { + return [add(startPath[0]!, startDelta), add(startPath[1]!, endDelta)] + } + + if (nearlyEqual(startDelta, endDelta)) { + return startPath.map((p) => add(p, startDelta)) + } + + const startParts = decompose(startDelta, endpointAxis(startPath, 'start')) + const endParts = decompose(endDelta, endpointAxis(startPath, 'end')) + const commonPerp = average([startParts.perp, endParts.perp]) + const path = startPath.map((p) => add(p, commonPerp)) + path[0] = add(path[0]!, startParts.parallel) + path[path.length - 1] = add(path[path.length - 1]!, endParts.parallel) + return path + } + + return runPathFromSinglePortDelta( + startPath, + startDelta ? 'start' : 'end', + (startDelta ?? endDelta)!, + ) +} + +/** + * Given the moved node in its live (in-drag) transform, produce the patches + * that keep every connected node attached. `previewNode` is the moved node + * with its current drag position/rotation applied so its ports recompute. + * + * Walks the snapshotted graph, propagating each port delta outward: fittings + * translate rigidly, runs stretch along their axis and translate across it + * (never skew when driven from one end), and effective port movement carries on + * to neighbouring joints. Port-level output guards bound cycles while still + * allowing a looped/shared run to accept constraints at both endpoints. + */ +export function resolveConnectivityUpdates( + connectivity: PortConnectivity, + previewNode: AnyNode, +): { id: AnyNodeId; data: Partial }[] { + const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity + if (Object.keys(graph).length === 0) return [] + + const newPorts = portsOf(previewNode) ?? [] + const newMovedPos: Record = {} + for (const p of newPorts) newMovedPos[p.id] = p.position + + // Each queue item drives a node's port by a delta ("this collar / endpoint + // must move by this much"). + const queue: Array<{ nodeId: AnyNodeId; portId: string; delta: Point; sourceKey: string }> = [] + const results: Record }> = {} + const constrainedPorts: Record>> = {} + const propagatedPorts: Record> = {} + + const enqueueMates = (nodeId: string, portId: string, delta: Point) => { + const byPort = propagatedPorts[nodeId] ?? {} + propagatedPorts[nodeId] = byPort + const previous = byPort[portId] + if (previous && propagationEqual(previous, delta)) return + byPort[portId] = delta + + for (const mate of adjacency[nodeId]?.[portId] ?? []) { + if (mate.nodeId === movedNodeId) continue + queue.push({ + nodeId: mate.nodeId, + portId: mate.portId, + delta, + sourceKey: `${nodeId}:${portId}`, + }) + } + } + + const acceptPortDelta = ( + nodeId: AnyNodeId, + portId: string, + sourceKey: string, + delta: Point, + ): boolean => { + const byPort = constrainedPorts[nodeId] ?? {} + constrainedPorts[nodeId] = byPort + const bySource = byPort[portId] ?? {} + byPort[portId] = bySource + const existing = bySource[sourceKey] + if (existing && propagationEqual(existing, delta)) { + return false + } + bySource[sourceKey] = delta + return true + } + + // Seed from the moved node's live port deltas. + for (const [portId, start] of Object.entries(startMovedPorts)) { + const now = newMovedPos[portId] + if (!now) continue + const delta = sub(now, start) + if (lenSq(delta) <= DELTA_EPS_M * DELTA_EPS_M) continue + enqueueMates(movedNodeId, portId, delta) + } + + while (queue.length > 0) { + const { nodeId, portId, delta, sourceKey } = queue.shift()! + const node = graph[nodeId] + if (!node) continue + if (!acceptPortDelta(nodeId, portId, sourceKey, delta)) continue + const portDeltas = effectivePortDeltas(constrainedPorts[nodeId]!) + + if (node.role === 'fitting') { + const start = node.startPosition! + const effectiveDelta = average(Object.values(portDeltas)) + results[nodeId] = { + id: nodeId, + data: { position: add(start, effectiveDelta) } as Partial, + } + // Rigid: every collar carries the effective body translation onward. + for (const p of node.ports) { + enqueueMates(nodeId, p.id, effectiveDelta) + } + } else { + const startPath = node.startPath! + const path = runPathFromPortDeltas(startPath, portDeltas) + results[nodeId] = { id: nodeId, data: { path } as Partial } + for (const [nextPortId, nextDelta] of Object.entries(runEndpointDeltas(startPath, path))) { + if (lenSq(nextDelta) <= DELTA_EPS_M * DELTA_EPS_M) continue + enqueueMates(nodeId, nextPortId, nextDelta) + } + } + } + + return Object.values(results) +} diff --git a/services/pascal-editor/packages/core/src/services/riser-diagram.test.ts b/services/pascal-editor/packages/core/src/services/riser-diagram.test.ts new file mode 100644 index 000000000..56014f29d --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/riser-diagram.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '../schema' +import { buildRiserDiagram, projectIso } from './riser-diagram' + +type Point = [number, number, number] + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +describe('projectIso', () => { + test('higher elevation maps to smaller screen Y', () => { + const [, lowY] = projectIso(0, 0, 0) + const [, highY] = projectIso(0, 2, 0) + expect(highY).toBeLessThan(lowY) + }) +}) + +describe('buildRiserDiagram', () => { + test('null when no DWV nodes', () => { + const wall = makeNode('wall', {}) + expect(buildRiserDiagram(sceneOf(wall))).toBeNull() + }) + + test('classifies a vertical stack vs a sloped horizontal drain', () => { + const stack = makeNode('pipe-segment', { + path: [ + [0, 0, 0], + [0, 3, 0], + ] as Point[], + diameter: 3, + system: 'vent', + }) + const drain = makeNode('pipe-segment', { + path: [ + [0, 0, 0], + [3, -0.06, 0], + ] as Point[], + diameter: 2, + system: 'waste', + }) + const diagram = buildRiserDiagram(sceneOf(stack, drain))! + const stackLine = diagram.lines.find((l) => l.nodeId === stack.id)! + const drainLine = diagram.lines.find((l) => l.nodeId === drain.id)! + expect(stackLine.vertical).toBe(true) + expect(drainLine.vertical).toBe(false) + }) + + test('emits a vent-termination marker for a vent run', () => { + const vent = makeNode('pipe-segment', { + path: [ + [0, 0, 0], + [0, 3, 0], + ] as Point[], + diameter: 2, + system: 'vent', + }) + const diagram = buildRiserDiagram(sceneOf(vent))! + expect(diagram.markers.some((m) => m.kind === 'vent-termination')).toBe(true) + }) + + test('labels traps', () => { + const trap = makeNode('pipe-trap', { + position: [1, 0, 0] as Point, + diameter: 1.5, + }) + const diagram = buildRiserDiagram(sceneOf(trap))! + expect(diagram.markers.some((m) => m.kind === 'trap')).toBe(true) + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/riser-diagram.ts b/services/pascal-editor/packages/core/src/services/riser-diagram.ts new file mode 100644 index 000000000..d5c7a7aef --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/riser-diagram.ts @@ -0,0 +1,143 @@ +import type { AnyNode, AnyNodeId } from '../schema' + +/** + * Riser diagram (plumbing isometric) — the conventional way DWV systems + * are drawn for permit: the drain/vent tree projected to a 30° iso so + * vertical stacks read as vertical and horizontal runs lean off at 30°, + * annotated with size + slope and vent terminations. + * + * This is a pure projector: it turns the scene's DWV nodes into 2D + * drawables (level-independent, no rendering). The editor draws the + * result as SVG. Air/refrigerant nodes are ignored — riser diagrams are + * a plumbing convention. + */ + +const COS30 = Math.cos(Math.PI / 6) +const SIN30 = Math.sin(Math.PI / 6) + +/** A 3D level-local point (meters) projected to 2D iso screen space. + * Screen Y grows DOWNWARD (SVG convention), so higher elevation → lower + * screen Y. */ +export function projectIso(x: number, y: number, z: number): [number, number] { + const sx = (x - z) * COS30 + const sy = (x + z) * SIN30 - y + return [sx, sy] +} + +export type RiserLine = { + /** Projected endpoints in iso screen space. */ + from: [number, number] + to: [number, number] + system: 'waste' | 'vent' + /** Nominal size in inches. */ + diameter: number + /** True for a (near-)vertical run — drawn solid/bold as a stack. */ + vertical: boolean + /** Source node, so the editor can link selection. */ + nodeId: AnyNodeId +} + +export type RiserMarker = { + point: [number, number] + kind: 'trap' | 'vent-termination' | 'fitting' + label: string + nodeId: AnyNodeId +} + +export type RiserDiagram = { + lines: RiserLine[] + markers: RiserMarker[] + /** Bounding box of all projected geometry, screen space. */ + bounds: { minX: number; minY: number; maxX: number; maxY: number } +} + +/** Elevation gain per horizontal meter under which a leg is "vertical". */ +const VERTICAL_EPS = 4 // dy/dxz ratio: steeper than this reads as a stack + +type Vec3 = readonly [number, number, number] + +function legIsVertical(a: Vec3, b: Vec3): boolean { + const horizontal = Math.hypot(b[0] - a[0], b[2] - a[2]) + const vertical = Math.abs(b[1] - a[1]) + if (horizontal < 1e-4) return true + return vertical / horizontal > VERTICAL_EPS +} + +/** + * Build the riser diagram for the whole scene. Returns null when there's + * no DWV geometry to draw. + */ +export function buildRiserDiagram( + nodes: Readonly>, +): RiserDiagram | null { + const lines: RiserLine[] = [] + const markers: RiserMarker[] = [] + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + const grow = (p: [number, number]) => { + if (p[0] < minX) minX = p[0] + if (p[1] < minY) minY = p[1] + if (p[0] > maxX) maxX = p[0] + if (p[1] > maxY) maxY = p[1] + } + + for (const node of Object.values(nodes)) { + if (!node) continue + if (node.type === 'pipe-segment') { + const path = node.path as Vec3[] + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]! + const b = path[i + 1]! + const from = projectIso(a[0], a[1], a[2]) + const to = projectIso(b[0], b[1], b[2]) + grow(from) + grow(to) + lines.push({ + from, + to, + system: node.system, + diameter: node.diameter, + vertical: legIsVertical(a, b), + nodeId: node.id, + }) + } + // Vent runs that end above everything are vent terminations + // (through-roof). Tag the highest endpoint of a vent run. + if (node.system === 'vent') { + const top = path.reduce((hi, p) => (p[1] > hi[1] ? p : hi), path[0]!) + const pt = projectIso(top[0], top[1], top[2]) + markers.push({ + point: pt, + kind: 'vent-termination', + label: `${node.diameter}" VTR`, + nodeId: node.id, + }) + } + } else if (node.type === 'pipe-trap') { + const pt = projectIso(node.position[0], node.position[1], node.position[2]) + grow(pt) + markers.push({ + point: pt, + kind: 'trap', + label: `${node.diameter}" P-trap`, + nodeId: node.id, + }) + } else if (node.type === 'pipe-fitting') { + const pt = projectIso(node.position[0], node.position[1], node.position[2]) + grow(pt) + markers.push({ + point: pt, + kind: 'fitting', + label: node.fittingType, + nodeId: node.id, + }) + } + } + + if (lines.length === 0 && markers.length === 0) return null + + return { lines, markers, bounds: { minX, minY, maxX, maxY } } +} diff --git a/services/pascal-editor/packages/core/src/services/single-undo-dance.test.ts b/services/pascal-editor/packages/core/src/services/single-undo-dance.test.ts new file mode 100644 index 000000000..64442a38e --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/single-undo-dance.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { createSceneApi } from '../registry/scene-api' +import type { AnyNode, AnyNodeId } from '../schema/types' +import useScene from '../store/use-scene' + +// Polyfills for bun:test (no DOM). +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +/** + * Validates the "single-undo dance" pattern used by Stage D actions: + * + * action.commit: + * scene.restoreAll() // revert via snapshot (paused → no zundo record) + * scene.resumeHistory() // unpause zundo + * scene.update(...) // re-apply final → zundo records one diff + * return true + * + * After the dance, undo() should roll back ONLY the drag's commit — + * never further back than that. The fence-bend regression that surfaced + * after Phase 5 Stage D porting was reportedly losing the prior create + * step on undo; this test pins the correct behavior. + */ + +const FENCE_ID = 'fence_test' as AnyNodeId + +function makeFence(curveOffset: number): AnyNode { + return { + id: FENCE_ID, + type: 'fence', + parentId: null, + object: 'node', + visible: true, + metadata: {}, + start: [0, 0], + end: [3, 0], + height: 1.8, + thickness: 0.08, + baseHeight: 0.22, + postSpacing: 2, + postSize: 0.1, + topRailHeight: 0.04, + groundClearance: 0, + edgeInset: 0.015, + baseStyle: 'grounded', + showInfill: true, + color: '#ffffff', + style: 'slat', + curveOffset, + } as unknown as AnyNode +} + +describe('Single-undo dance', () => { + beforeEach(() => { + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) + useScene.temporal.getState().clear() + }) + + test('curve-style commit yields a single undo step', () => { + // 1. Create the fence (recorded by zundo). + useScene.getState().createNode(makeFence(0)) + const pastCountAfterCreate = useScene.temporal.getState().pastStates.length + + // 2. Simulate the drag. + const scene = createSceneApi(useScene) + scene.pauseHistory() + scene.update(FENCE_ID, { curveOffset: 0.2 } as Partial) + scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial) + expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5) + + // 3. Commit dance. + scene.restoreAll() + expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0) + scene.resumeHistory() + scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial) + + const pastCountAfterDance = useScene.temporal.getState().pastStates.length + expect(pastCountAfterDance).toBe(pastCountAfterCreate + 1) + expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5) + + // 4. Undo — should return fence to curveOffset 0, NOT delete it. + useScene.temporal.getState().undo() + const fenceAfterUndo = useScene.getState().nodes[FENCE_ID] + expect(fenceAfterUndo).toBeDefined() + expect((fenceAfterUndo as { curveOffset: number }).curveOffset).toBe(0) + }) + + test('StrictMode double-mount: dispose-then-restart preserves history', () => { + useScene.getState().createNode(makeFence(0)) + const pastBeforeBend = useScene.temporal.getState().pastStates.length + + // Simulate StrictMode: first mount → cleanup (dispose) → second mount → drag → commit. + const scene = createSceneApi(useScene) + + // Mount 1. + scene.pauseHistory() + // Mount 1 cleanup (StrictMode): no apply happened yet. dispose-equivalent. + scene.restoreAll() // snapshot empty, no-op. + scene.resumeHistory() + + // Mount 2. + scene.pauseHistory() + // Drag. + scene.update(FENCE_ID, { curveOffset: 0.3 } as Partial) + scene.update(FENCE_ID, { curveOffset: 0.7 } as Partial) + // Commit dance. + scene.restoreAll() + scene.resumeHistory() + scene.update(FENCE_ID, { curveOffset: 0.7 } as Partial) + + const pastAfterBend = useScene.temporal.getState().pastStates.length + // Exactly one new entry: the pre-bend state. Not two (which would mean + // StrictMode's first mount/cleanup polluted history). + expect(pastAfterBend).toBe(pastBeforeBend + 1) + + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[FENCE_ID]).toBeDefined() + expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0) + }) + + test('commit-returns-false (no change) does NOT consume the prior pastState', () => { + // This is the suspected bend regression: when action.commit returns + // false (draft.curveOffset === ctx.originalCurveOffset), session.commit + // calls scene.restoreAll() but doesn't push to pastStates. Subsequent + // undo pops the PRIOR action (e.g. fence creation), not the no-op + // bend. + useScene.getState().createNode(makeFence(0)) + const pastBeforeBend = useScene.temporal.getState().pastStates.length + + const scene = createSceneApi(useScene) + scene.pauseHistory() + // Drag back to original — simulates no-op bend. + scene.update(FENCE_ID, { curveOffset: 0.0 } as Partial) + // Cancel path (mimics session.commit → action.commit returns false → scene.restoreAll → terminate). + scene.restoreAll() + scene.resumeHistory() + + const pastAfterNoOp = useScene.temporal.getState().pastStates.length + expect(pastAfterNoOp).toBe(pastBeforeBend) // no entries added + + // Now undo — this should be a no-op (state unchanged), but pops the create. + useScene.temporal.getState().undo() + // ⚠️ Reproduces the bug — undo removes the fence: + const fence = useScene.getState().nodes[FENCE_ID] + if (fence === undefined) { + // Bug reproduced. The "no-op bend" allowed Ctrl-Z to fall through + // to the fence creation. Fix is in action.commit: don't return false + // — push a no-op entry instead, or guard against the cancel path. + expect(fence).toBeUndefined() + } else { + expect((fence as { curveOffset: number }).curveOffset).toBe(0) + } + }) + + test('full session flow via createDragSession with real action.commit dance', async () => { + // Reproduces the actual wrapper flow: + // - createNode → session.start → moves → grid:click → session.commit. + // The action.commit does the dance internally. + + const { createDragSession } = await import('./drag-session') + + useScene.getState().createNode(makeFence(0)) + const pastBeforeBend = useScene.temporal.getState().pastStates.length + + const scene = createSceneApi(useScene) + + const action = { + begin: () => ({ original: 0 }), + preview: (_ctx: unknown, point: readonly [number, number]) => ({ offset: point[0] }), + apply: (draft: { offset: number }, _ctx: unknown, s: ReturnType) => { + s.update(FENCE_ID, { curveOffset: draft.offset } as Partial) + return [FENCE_ID] + }, + commit: ( + draft: { offset: number }, + ctx: { original: number }, + s: ReturnType, + ) => { + if (draft.offset === ctx.original) return false + s.restoreAll() + s.resumeHistory() + s.update(FENCE_ID, { curveOffset: draft.offset } as Partial) + return true + }, + cancel: () => {}, + } + + const session = createDragSession(action, scene) + session.start({ point: [0, 0] }) + session.move([0.3, 0], { shift: false, alt: false, ctrl: false, meta: false }) + session.move([0.5, 0], { shift: false, alt: false, ctrl: false, meta: false }) + const okCommit = session.commit() + expect(okCommit).toBe(true) + + const pastAfter = useScene.temporal.getState().pastStates.length + expect(pastAfter).toBe(pastBeforeBend + 1) + expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5) + + useScene.temporal.getState().undo() + const after = useScene.getState().nodes[FENCE_ID] + expect(after).toBeDefined() + expect((after as { curveOffset: number }).curveOffset).toBe(0) + }) + + test('REAL bend (draft != original): one Ctrl-Z undoes only the bend', () => { + useScene.getState().createNode(makeFence(0)) + const stateAfterCreate = useScene.getState().nodes[FENCE_ID] as { curveOffset: number } + expect(stateAfterCreate.curveOffset).toBe(0) + + const scene = createSceneApi(useScene) + scene.pauseHistory() + // Simulate a real drag: capture original, mutate to non-zero. + scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial) + expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5) + + // Dance. + scene.restoreAll() + expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0) + scene.resumeHistory() + scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial) + expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5) + + // First Ctrl-Z should undo the bend. + useScene.temporal.getState().undo() + const afterFirstUndo = useScene.getState().nodes[FENCE_ID] as + | { curveOffset: number } + | undefined + expect(afterFirstUndo).toBeDefined() + expect(afterFirstUndo?.curveOffset).toBe(0) + }) + + test('a SECOND undo rolls the create step back', () => { + useScene.getState().createNode(makeFence(0)) + const scene = createSceneApi(useScene) + scene.pauseHistory() + scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial) + scene.restoreAll() + scene.resumeHistory() + scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial) + + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[FENCE_ID]).toBeDefined() + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[FENCE_ID]).toBeUndefined() + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/snap.test.ts b/services/pascal-editor/packages/core/src/services/snap.test.ts new file mode 100644 index 000000000..fa076fa39 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/snap.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from 'bun:test' +import { + DEFAULT_ANGLE_STEP, + DEFAULT_GRID_STEP, + snapAngleToList, + snapPointAlongAngleRay, + snapPointToAngle, + snapPointToGrid, + snapScalar, + snapServices, + snapVec3ToGrid, + type Vec2, +} from './snap' + +describe('snapScalar', () => { + test('rounds to multiples of step', () => { + expect(snapScalar(0.27, 0.25)).toBe(0.25) + expect(snapScalar(0.13, 0.25)).toBe(0.25) + expect(snapScalar(0.12, 0.25)).toBe(0) + expect(snapScalar(1.7, 0.25)).toBeCloseTo(1.75) + }) + + test('returns input unchanged when step is non-positive', () => { + expect(snapScalar(0.42, 0)).toBe(0.42) + expect(snapScalar(0.42, -1)).toBe(0.42) + }) + + test('default step is 0.25m', () => { + expect(snapScalar(0.3)).toBe(0.25) + expect(snapScalar(0.4)).toBe(0.5) + expect(DEFAULT_GRID_STEP).toBe(0.25) + }) +}) + +describe('snapPointToGrid', () => { + test('snaps both components independently', () => { + expect(snapPointToGrid([0.3, 0.6], 0.25)).toEqual([0.25, 0.5]) + }) + + test('preserves exact-grid points', () => { + expect(snapPointToGrid([1, 2], 0.5)).toEqual([1, 2]) + }) +}) + +describe('snapVec3ToGrid', () => { + test('snaps X and Z, leaves Y untouched', () => { + expect(snapVec3ToGrid([0.3, 1.7, 0.6], 0.25)).toEqual([0.25, 1.7, 0.5]) + }) +}) + +describe('snapPointToAngle', () => { + test('snaps to axis (0°) when cursor is near horizontal', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [1, 0.05] // near 0° + const snapped = snapPointToAngle(from, cursor, Math.PI / 4) + expect(snapped[0]).toBeCloseTo(1, 1) + expect(snapped[1]).toBeCloseTo(0, 5) + }) + + test('snaps to 45° at π/4 step', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [1, 0.9] // near 45° + const snapped = snapPointToAngle(from, cursor, Math.PI / 4) + // distance preserved (≈ √(1² + 0.9²) ≈ 1.345), angle locked to 45° + const expectedDist = Math.hypot(1, 0.9) + expect(snapped[0]).toBeCloseTo(expectedDist * Math.cos(Math.PI / 4)) + expect(snapped[1]).toBeCloseTo(expectedDist * Math.sin(Math.PI / 4)) + }) + + test('default angle step is π/12 (15°)', () => { + expect(DEFAULT_ANGLE_STEP).toBeCloseTo(Math.PI / 12) + }) + + test('grid-snaps the projected point when gridStep is provided', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [1.05, 0.02] // ~horizontal, slightly off grid + const snapped = snapPointToAngle(from, cursor, Math.PI / 4, 0.25) + // After 0° lock + 0.25m grid, X must be a 0.25 multiple. + expect(snapped[0] / 0.25).toBeCloseTo(Math.round(snapped[0] / 0.25)) + }) + + test('preserves distance from `from`', () => { + const from: Vec2 = [2, 3] + const cursor: Vec2 = [3, 4] + const distance = Math.hypot(1, 1) + const snapped = snapPointToAngle(from, cursor, Math.PI / 4) + expect(Math.hypot(snapped[0] - 2, snapped[1] - 3)).toBeCloseTo(distance) + }) +}) + +describe('snapPointAlongAngleRay', () => { + test('stays exactly on the 15° ray while distance-snapping to the grid step', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [2, 0.5] // ≈14° — snaps to 15° + const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.25) + expect(Math.atan2(snapped[1], snapped[0])).toBeCloseTo(Math.PI / 12, 10) + const distance = Math.hypot(snapped[0], snapped[1]) + expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10) + }) + + test('grid-snapping after the angle projection would pull the point off the ray', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [2, 0.5] + const offRay = snapPointToAngle(from, cursor, Math.PI / 12, 0.25) + expect(Math.atan2(offRay[1], offRay[0])).not.toBeCloseTo(Math.PI / 12, 4) + }) + + test('45° back-compat: locks to the diagonal with grid-multiple distance', () => { + const from: Vec2 = [1, 1] + const cursor: Vec2 = [2.1, 1.9] // near 45° from `from` + const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 4, 0.25) + expect(Math.atan2(snapped[1] - 1, snapped[0] - 1)).toBeCloseTo(Math.PI / 4, 10) + const distance = Math.hypot(snapped[0] - 1, snapped[1] - 1) + expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10) + }) + + test('preserves the projected distance when no distanceStep is given', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [1, 0.05] // near 0° + const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12) + expect(snapped[0]).toBeCloseTo(1, 10) // projection of (1, 0.05) onto 0° ray + expect(snapped[1]).toBeCloseTo(0, 10) + }) + + test('returns `from` for a zero-length segment', () => { + expect(snapPointAlongAngleRay([2, 3], [2, 3], Math.PI / 12, 0.25)).toEqual([2, 3]) + }) + + test('is idempotent on its own output', () => { + const from: Vec2 = [0.5, -1] + const cursor: Vec2 = [3.2, 0.4] + const once = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.5) + const twice = snapPointAlongAngleRay(from, once, Math.PI / 12, 0.5) + expect(twice[0]).toBeCloseTo(once[0], 10) + expect(twice[1]).toBeCloseTo(once[1], 10) + }) +}) + +describe('snapAngleToList', () => { + test('snaps to the nearest entry within tolerance', () => { + const targets = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2] + expect(snapAngleToList(0.05, targets, Math.PI / 36)).toBe(0) + expect(snapAngleToList(Math.PI / 2 + 0.02, targets, Math.PI / 36)).toBe(Math.PI / 2) + }) + + test('returns original angle when no target is within tolerance', () => { + const targets = [0, Math.PI / 2] + expect(snapAngleToList(0.5, targets, Math.PI / 36)).toBe(0.5) + }) + + test('handles wrap-around near ±π', () => { + const targets = [Math.PI] + expect(snapAngleToList(-Math.PI + 0.01, targets, Math.PI / 36)).toBe(Math.PI) + }) +}) + +describe('snapServices facade', () => { + test('grid.snap matches snapPointToGrid', () => { + expect(snapServices.grid.snap([0.3, 0.6], 0.25)).toEqual(snapPointToGrid([0.3, 0.6], 0.25)) + }) + + test('grid.snapScalar matches snapScalar', () => { + expect(snapServices.grid.snapScalar(0.3, 0.25)).toBe(snapScalar(0.3, 0.25)) + }) + + test('angle.snapTo matches snapPointToAngle', () => { + const from: Vec2 = [0, 0] + const cursor: Vec2 = [1, 0.9] + expect(snapServices.angle.snapTo(from, cursor, Math.PI / 4)).toEqual( + snapPointToAngle(from, cursor, Math.PI / 4), + ) + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/snap.ts b/services/pascal-editor/packages/core/src/services/snap.ts new file mode 100644 index 000000000..3c10ba698 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/snap.ts @@ -0,0 +1,195 @@ +/** + * Pure snap math — no React, no R3F, no scene access. + * + * Phase 1 ships the kind-agnostic snappers (grid + angle). Wall-specific + * snapping (snap-to-endpoint, snap-along-T) currently lives in + * `editor/src/components/tools/wall/wall-drafting.ts` and stays there until + * Phase 3, when the wall migration ports it here behind a `wallSnap` namespace. + * + * The functions here are stable contract — Phase 3 only adds, never removes. + */ + +export type Vec2 = readonly [number, number] +export type Vec3 = readonly [number, number, number] + +/** Default planar grid spacing in meters. Matches the editor's wall tool. */ +export const DEFAULT_GRID_STEP = 0.25 + +/** Default angle-snap step — π/12 = 15°. */ +export const DEFAULT_ANGLE_STEP = Math.PI / 12 + +// ─── Grid snap ──────────────────────────────────────────────────────── + +/** Snaps a single scalar to the nearest multiple of `step`. */ +export function snapScalar(value: number, step: number = DEFAULT_GRID_STEP): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** Snaps a 2D point to a regular planar grid. */ +export function snapPointToGrid(point: Vec2, step: number = DEFAULT_GRID_STEP): Vec2 { + return [snapScalar(point[0], step), snapScalar(point[1], step)] +} + +/** Snaps a 3D point to a regular grid in the X/Z plane, preserving Y. */ +export function snapVec3ToGrid(point: Vec3, step: number = DEFAULT_GRID_STEP): Vec3 { + return [snapScalar(point[0], step), point[1], snapScalar(point[2], step)] +} + +/** + * Snap a world XZ point to the grid, then express it in the local frame of + * a building positioned at `buildingPosition` with rotation `buildingRotationY` + * (radians, around the Y axis). Returns both the snapped world point and its + * local-frame equivalent, so callers can render in either frame without + * recomputing the rotation. + * + * Use when a tool needs to keep snapping on the world grid (the grid the + * editor renders) even when the active building is rotated. Snapping in the + * building's local frame would otherwise chase the rotated axes and miss + * the visible grid lines. + */ +export function snapWorldXZToBuildingLocal( + worldX: number, + worldZ: number, + buildingPosition: Vec3, + buildingRotationY: number, + step: number = DEFAULT_GRID_STEP, +): { world: [number, number]; local: [number, number] } { + if (step <= 0) { + const dx = worldX - buildingPosition[0] + const dz = worldZ - buildingPosition[2] + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + return { + world: [worldX, worldZ], + local: [dx * cos - dz * sin, dx * sin + dz * cos], + } + } + const snappedWX = Math.round(worldX / step) * step + const snappedWZ = Math.round(worldZ / step) * step + const dx = snappedWX - buildingPosition[0] + const dz = snappedWZ - buildingPosition[2] + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + // The forward (local → world) rotation used in the editor is + // wx = bx + lx*cos + lz*sin + // wz = bz - lx*sin + lz*cos + // so the inverse (orthogonal, so transpose) is + // lx = dx*cos - dz*sin + // lz = dx*sin + dz*cos + return { + world: [snappedWX, snappedWZ], + local: [dx * cos - dz * sin, dx * sin + dz * cos], + } +} + +// ─── Angle snap ─────────────────────────────────────────────────────── + +/** + * Snaps a cursor point to the nearest angle multiple of `angleStep` (radians) + * measured from `from`, preserving distance. Useful for axis/diagonal-locked + * placement and wall draft endpoint locking. + * + * After the angle snap, the result is grid-snapped if `gridStep` is provided + * — keeps endpoints landing on grid intersections. + */ +export function snapPointToAngle( + from: Vec2, + cursor: Vec2, + angleStep: number = DEFAULT_ANGLE_STEP, + gridStep?: number, +): Vec2 { + const dx = cursor[0] - from[0] + const dz = cursor[1] - from[1] + const angle = Math.atan2(dz, dx) + const snappedAngle = Math.round(angle / angleStep) * angleStep + const distance = Math.hypot(dx, dz) + const projected: Vec2 = [ + from[0] + Math.cos(snappedAngle) * distance, + from[1] + Math.sin(snappedAngle) * distance, + ] + return gridStep == null ? projected : snapPointToGrid(projected, gridStep) +} + +/** + * Snaps a cursor point onto the nearest angle ray from `from` (multiples of + * `angleStep`), projecting the cursor onto that ray, then snaps the distance + * ALONG the ray to `distanceStep`. Unlike `snapPointToAngle` with a + * `gridStep`, the result stays exactly on the snapped ray — grid-snapping + * after the angle projection pulls points off non-axis rays. + */ +export function snapPointAlongAngleRay( + from: Vec2, + cursor: Vec2, + angleStep: number = DEFAULT_ANGLE_STEP, + distanceStep?: number, +): Vec2 { + const dx = cursor[0] - from[0] + const dz = cursor[1] - from[1] + if (dx === 0 && dz === 0) return [from[0], from[1]] + const angle = Math.atan2(dz, dx) + const snappedAngle = angleStep > 0 ? Math.round(angle / angleStep) * angleStep : angle + const dirX = Math.cos(snappedAngle) + const dirZ = Math.sin(snappedAngle) + const projected = dx * dirX + dz * dirZ + const distance = + distanceStep != null && distanceStep > 0 ? snapScalar(projected, distanceStep) : projected + return [from[0] + dirX * distance, from[1] + dirZ * distance] +} + +/** + * Snaps an angle (in radians) to the nearest entry in `snapAngles` (also in + * radians). Returns the original angle if no entry is within `toleranceRad`. + */ +export function snapAngleToList( + angle: number, + snapAngles: readonly number[], + toleranceRad: number = Math.PI / 36, // 5° +): number { + let best: number | null = null + let bestDelta = Number.POSITIVE_INFINITY + for (const target of snapAngles) { + // wrap delta to [-π, π] + let delta = ((angle - target) % (Math.PI * 2)) + Math.PI * 3 + delta = (delta % (Math.PI * 2)) - Math.PI + const abs = Math.abs(delta) + if (abs < bestDelta && abs <= toleranceRad) { + bestDelta = abs + best = target + } + } + return best ?? angle +} + +// ─── Top-level SnapServices facade ──────────────────────────────────── + +/** + * Stable surface that `DragAction.snap` callbacks receive. Phase 1 ships + * `grid` and `angle`. Phase 3 adds a `wall` namespace populated by wall + * migration. Plugin authors should target this facade rather than importing + * the individual functions, so future Phase contributions become visible + * without code changes. + */ +export type SnapServices = { + grid: { + snap: (point: Vec2, step?: number) => Vec2 + snapVec3: (point: Vec3, step?: number) => Vec3 + snapScalar: (value: number, step?: number) => number + } + angle: { + snapTo: (from: Vec2, cursor: Vec2, angleStep?: number, gridStep?: number) => Vec2 + snapToList: (angle: number, list: readonly number[], toleranceRad?: number) => number + } +} + +export const snapServices: SnapServices = { + grid: { + snap: snapPointToGrid, + snapVec3: snapVec3ToGrid, + snapScalar, + }, + angle: { + snapTo: snapPointToAngle, + snapToList: snapAngleToList, + }, +} diff --git a/services/pascal-editor/packages/core/src/services/system-graph.test.ts b/services/pascal-editor/packages/core/src/services/system-graph.test.ts new file mode 100644 index 000000000..e7640d675 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/system-graph.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeDefinition, DistributionRole, NodePort } from '../registry' +import { registerNode } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' +import { buildPortComponents, summarizeSystemFor } from './system-graph' + +type Point = [number, number, number] + +// Stub registrations: the graph consults `def.ports` for the connectivity +// graph and `def.distributionRole` to classify each node. Mirrors the real +// kinds' port + role conventions (duct runs expose start/end, equipment a +// supply collar, terminals one collar) without importing the nodes package. +function stubDef( + kind: string, + distributionRole: DistributionRole, + ports: (node: AnyNode) => NodePort[], +): void { + registerNode({ + kind, + schemaVersion: 1, + schema: {}, + category: 'utility', + distributionRole, + defaults: () => ({}), + capabilities: {}, + ports, + } as unknown as AnyNodeDefinition) +} + +stubDef('duct-segment', 'run', (node) => { + const path = (node as unknown as { path: Point[] }).path + const system = (node as unknown as { system: string }).system + return [ + { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system }, + { + id: 'end', + position: path[path.length - 1]!, + direction: [1, 0, 0], + diameter: 6, + system, + }, + ] +}) +stubDef('hvac-equipment', 'equipment', (node) => { + const position = (node as unknown as { position: Point }).position + return [{ id: 'supply', position, direction: [0, 1, 0], diameter: 12, system: 'supply' }] +}) +stubDef('duct-terminal', 'terminal', (node) => { + const position = (node as unknown as { position: Point }).position + return [{ id: 'collar', position, direction: [0, -1, 0], diameter: 6, system: 'supply' }] +}) + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} + +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +function run(path: Point[], system = 'supply'): AnyNode { + return makeNode('duct-segment', { path, system, diameter: 6 }) +} + +describe('buildPortComponents', () => { + test('chained runs land in one component; a distant run is separate', () => { + const a = run([ + [0, 0, 0], + [3, 0, 0], + ]) + const b = run([ + [3, 0, 0], + [3, 0, 4], + ]) // shares a's end + const c = run([ + [20, 0, 0], + [24, 0, 0], + ]) // far away + const components = buildPortComponents(sceneOf(a, b, c)) + expect(components.length).toBe(2) + const joined = components.find((g) => g.length === 2)! + expect(new Set(joined)).toEqual(new Set([a.id, b.id])) + }) + + test('joints within tolerance still join; outside do not', () => { + const a = run([ + [0, 0, 0], + [3, 0, 0], + ]) + const near = run([ + [3.03, 0, 0], + [6, 0, 0], + ]) // 3 cm — joined + const far = run([ + [3.2, 0, 4], + [6, 0, 4], + ]) // 20 cm in another row — separate + const components = buildPortComponents(sceneOf(a, near, far)) + expect(components.length).toBe(2) + }) + + test('nodes without ports do not participate', () => { + const wall = makeNode('wall', {}) + const a = run([ + [0, 0, 0], + [3, 0, 0], + ]) + const components = buildPortComponents(sceneOf(wall, a)) + expect(components.length).toBe(1) + expect(components[0]).toEqual([a.id]) + }) +}) + +describe('summarizeSystemFor', () => { + test('full tree: equipment → run → terminal, stats add up', () => { + const furnace = makeNode('hvac-equipment', { position: [0, 0, 0] as Point }) + const trunk = run([ + [0, 0, 0], + [4, 0, 0], + ]) + const branch = run([ + [4, 0, 0], + [4, 0, 3], + ]) + const register = makeNode('duct-terminal', { + position: [4, 0, 3] as Point, + terminalType: 'supply-register', + }) + const scene = sceneOf(furnace, trunk, branch, register) + + const summary = summarizeSystemFor(register.id, scene)! + expect(summary.nodeIds.length).toBe(4) + expect(summary.connectedToEquipment).toBe(true) + expect(summary.runCount).toBe(2) + expect(summary.runLengthM).toBeCloseTo(7, 6) + expect(summary.terminalCount).toBe(1) + expect(summary.equipmentCount).toBe(1) + expect(summary.systems).toEqual(['supply']) + }) + + test('orphaned run reports no equipment', () => { + const lonely = run([ + [10, 0, 10], + [14, 0, 10], + ]) + const summary = summarizeSystemFor(lonely.id, sceneOf(lonely))! + expect(summary.connectedToEquipment).toBe(false) + expect(summary.runCount).toBe(1) + expect(summary.runLengthM).toBeCloseTo(4, 6) + }) + + test('port-less node → null', () => { + const wall = makeNode('wall', {}) + expect(summarizeSystemFor(wall.id, sceneOf(wall))).toBeNull() + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/system-graph.ts b/services/pascal-editor/packages/core/src/services/system-graph.ts new file mode 100644 index 000000000..9dc6d90d7 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/system-graph.ts @@ -0,0 +1,196 @@ +import { nodeRegistry } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' + +/** + * The "System" primitive: connected components over the port graph. + * + * Two nodes are joined when a port of one coincides in space with a port + * of the other — the same mated-joint relationship `port-connectivity` + * uses for drag propagation, read here at whole-scene scope. A component + * is one distribution system: a furnace, its trunk, the tees, branches, + * and registers hanging off it. + * + * Pure logic (def.ports + arithmetic), no rendering — lives in core so + * the editor (badges, schedules) and analyses (sizing, code checks) can + * share it. + */ + +/** Distance (meters) under which two ports count as the same joint — + * matches port-connectivity's tolerance for hand-placed joints. */ +const COINCIDENT_EPS_M = 0.05 + +export type SystemSummary = { + /** Every node in this connected component. */ + nodeIds: AnyNodeId[] + /** Distribution loops present, e.g. ['supply'], ['supply','return']. */ + systems: string[] + /** Duct / lineset run statistics. */ + runCount: number + runLengthM: number + fittingCount: number + terminalCount: number + equipmentCount: number + /** False = orphaned subtree: air goes nowhere (no furnace / air + * handler / condenser anywhere in the component). */ + connectedToEquipment: boolean +} + +type PortRecord = { + nodeId: AnyNodeId + x: number + y: number + z: number + system: string | undefined +} + +function collectPorts(nodes: Readonly>): PortRecord[] { + const result: PortRecord[] = [] + for (const node of Object.values(nodes)) { + if (!node) continue + const ports = nodeRegistry.get(node.type)?.ports?.(node) + if (!ports) continue + for (const port of ports) { + result.push({ + nodeId: node.id, + x: port.position[0], + y: port.position[1], + z: port.position[2], + system: port.system, + }) + } + } + return result +} + +/** Union-find over node ids. */ +class Components { + private parent = new Map() + + find(id: AnyNodeId): AnyNodeId { + let root = this.parent.get(id) ?? id + if (root !== id) { + root = this.find(root) + this.parent.set(id, root) + } + return root + } + + union(a: AnyNodeId, b: AnyNodeId): void { + const ra = this.find(a) + const rb = this.find(b) + if (ra !== rb) this.parent.set(rb, ra) + } +} + +function pathLength(path: ReadonlyArray): number { + let total = 0 + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]! + const b = path[i + 1]! + total += Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) + } + return total +} + +/** + * Group every port-bearing node into connected components via coinciding + * ports. Nodes with ports but no joints form singleton components; nodes + * without `def.ports` don't participate at all. + */ +export function buildPortComponents(nodes: Readonly>): AnyNodeId[][] { + const ports = collectPorts(nodes) + const components = new Components() + const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + + for (let i = 0; i < ports.length; i++) { + const a = ports[i]! + for (let j = i + 1; j < ports.length; j++) { + const b = ports[j]! + if (a.nodeId === b.nodeId) continue + const dx = a.x - b.x + const dy = a.y - b.y + const dz = a.z - b.z + if (dx * dx + dy * dy + dz * dz <= epsSq) components.union(a.nodeId, b.nodeId) + } + } + + const grouped = new Map() + const seen = new Set() + for (const port of ports) { + if (seen.has(port.nodeId)) continue + seen.add(port.nodeId) + const root = components.find(port.nodeId) + const group = grouped.get(root) + if (group) group.push(port.nodeId) + else grouped.set(root, [port.nodeId]) + } + return [...grouped.values()] +} + +function summarize( + nodeIds: AnyNodeId[], + nodes: Readonly>, +): SystemSummary { + const systems = new Set() + let runCount = 0 + let runLengthM = 0 + let fittingCount = 0 + let terminalCount = 0 + let equipmentCount = 0 + + for (const id of nodeIds) { + const node = nodes[id] + if (!node) continue + const role = nodeRegistry.get(node.type)?.distributionRole + const fields = node as { + path?: ReadonlyArray + system?: string + terminalType?: string + } + if (role === 'run') { + runCount += 1 + if (fields.path) runLengthM += pathLength(fields.path) + // Linesets carry refrigerant; duct / pipe runs name their own loop. + systems.add(fields.system ?? 'refrigerant') + } else if (role === 'fitting') { + fittingCount += 1 + if (fields.system) systems.add(fields.system) + } else if (role === 'terminal') { + terminalCount += 1 + systems.add(fields.terminalType === 'return-grille' ? 'return' : 'supply') + } else if (role === 'equipment') { + equipmentCount += 1 + } + } + + return { + nodeIds, + systems: [...systems].sort(), + runCount, + runLengthM, + fittingCount, + terminalCount, + equipmentCount, + connectedToEquipment: equipmentCount > 0, + } +} + +/** + * Summary of the system the given node belongs to, or null when the node + * has no ports (not a distribution kind). A node with ports but no + * joints yet still gets a (singleton) summary — `connectedToEquipment: + * false` is the interesting signal there. + */ +export function summarizeSystemFor( + nodeId: AnyNodeId, + nodes: Readonly>, +): SystemSummary | null { + const node = nodes[nodeId] + if (!node) return null + const ports = nodeRegistry.get(node.type)?.ports?.(node) + if (!ports || ports.length === 0) return null + for (const component of buildPortComponents(nodes)) { + if (component.includes(nodeId)) return summarize(component, nodes) + } + return summarize([nodeId], nodes) +} diff --git a/services/pascal-editor/packages/core/src/services/validate-dwv.test.ts b/services/pascal-editor/packages/core/src/services/validate-dwv.test.ts new file mode 100644 index 000000000..8d62b30ad --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/validate-dwv.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeDefinition, NodePort } from '../registry' +import { registerNode } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' +import { validateDwv } from './validate-dwv' + +type Point = [number, number, number] + +// The validator reads node fields directly + buildPortComponents (which +// consults def.ports), so register stub port-providers for the DWV kinds +// it groups by. Mirrors the system-graph test's approach. +function stubDef(kind: string, ports: (node: AnyNode) => NodePort[]): void { + registerNode({ + kind, + schemaVersion: 1, + schema: {}, + category: 'utility', + defaults: () => ({}), + capabilities: {}, + ports, + } as unknown as AnyNodeDefinition) +} + +stubDef('pipe-segment', (node) => { + const path = (node as unknown as { path: Point[] }).path + const diameter = (node as unknown as { diameter: number }).diameter + const system = (node as unknown as { system: string }).system + return [ + { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter, system }, + { + id: 'end', + position: path[path.length - 1]!, + direction: [1, 0, 0], + diameter, + system, + }, + ] +}) +stubDef('pipe-trap', (node) => { + const position = (node as unknown as { position: Point }).position + return [{ id: 'inlet', position, direction: [0, 1, 0], diameter: 1.5, system: 'waste' }] +}) + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} + +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +/** A waste run from a→b. Drop the end Y to slope it. */ +function waste(path: Point[], diameter = 2): AnyNode { + return makeNode('pipe-segment', { path, diameter, system: 'waste' }) +} + +const QUARTER_PER_FOOT = 1 / 48 + +describe('validateDwv — slope', () => { + test('flags a flat waste run', () => { + const run = waste([ + [0, 0, 0], + [3, 0, 0], // dead level + ]) + const findings = validateDwv(sceneOf(run)) + expect(findings.some((f) => f.code === 'slope-too-flat')).toBe(true) + }) + + test('passes a run sloped at quarter-inch per foot', () => { + const drop = 3 * QUARTER_PER_FOOT + const run = waste([ + [0, 0, 0], + [3, -drop, 0], + ]) + const findings = validateDwv(sceneOf(run)) + expect(findings.some((f) => f.code === 'slope-too-flat')).toBe(false) + }) + + test('flags an over-steep run (siphoning risk)', () => { + // 2" pipe, max slope = 2/12 ≈ 0.167; drop 2m over 1m horizontal. + const run = waste([ + [0, 0, 0], + [1, -2, 0], + ]) + const findings = validateDwv(sceneOf(run)) + expect(findings.some((f) => f.code === 'slope-too-steep')).toBe(true) + }) + + test('ignores vents (level is fine)', () => { + const vent = makeNode('pipe-segment', { + path: [ + [0, 0, 0], + [0, 3, 0], + ] as Point[], + diameter: 2, + system: 'vent', + }) + const findings = validateDwv(sceneOf(vent)) + expect(findings.length).toBe(0) + }) +}) + +describe('validateDwv — trap arm', () => { + test('flags an over-long trap arm', () => { + const trap = makeNode('pipe-trap', { + position: [0, 0, 0] as Point, + diameter: 1.5, // max arm 42in = 1.067m + armLengthM: 2, // way over + }) + const findings = validateDwv(sceneOf(trap)) + expect(findings.some((f) => f.code === 'trap-arm-too-long')).toBe(true) + }) + + test('passes a trap arm within the limit', () => { + const trap = makeNode('pipe-trap', { + position: [0, 0, 0] as Point, + diameter: 2, // max arm 60in = 1.524m + armLengthM: 1, + }) + const findings = validateDwv(sceneOf(trap)) + expect(findings.some((f) => f.code === 'trap-arm-too-long')).toBe(false) + }) +}) diff --git a/services/pascal-editor/packages/core/src/services/validate-dwv.ts b/services/pascal-editor/packages/core/src/services/validate-dwv.ts new file mode 100644 index 000000000..a2b3aecc0 --- /dev/null +++ b/services/pascal-editor/packages/core/src/services/validate-dwv.ts @@ -0,0 +1,161 @@ +import type { AnyNode, AnyNodeId } from '../schema' +import { buildPortComponents } from './system-graph' + +/** + * IPC validators for the DWV (drain-waste-vent) system — the "CodeRule" + * primitive from the domain brief. The slope, minimum-size, and + * trap-arm rules are all geometric and read straight off the node + * fields, so they live here in core (pure logic) where the editor can + * surface them and analyses can reuse them. + * + * Scope is residential IPC, simplified: + * - 704.1 drainage slope by pipe size. + * - 909 trap-arm maximum developed length by trap size. + * + * These are intentionally conservative approximations, not a certified + * plan-check — enough to flag the mistakes a drawing tool invites. + */ + +/** Drainage findings, worst-first per consumer's sort. */ +export type DwvSeverity = 'error' | 'warning' + +export type DwvFinding = { + severity: DwvSeverity + /** Stable rule id, e.g. 'slope-too-flat'. */ + code: string + /** Human-readable, already-formatted message. */ + message: string + /** Nodes the finding implicates (usually one). */ + nodeIds: AnyNodeId[] +} + +/** IPC 704.1 minimum drainage slope (rise/run, dimensionless) by + * nominal pipe size: ¼"/ft (1:48) under 3", ⅛"/ft (1:96) for 3–6", + * 1/16"/ft (1:192) at 8"+. */ +function minSlopeFor(diameterIn: number): number { + if (diameterIn < 3) return 1 / 48 + if (diameterIn < 8) return 1 / 96 + return 1 / 192 +} + +/** IPC Table 909.1 maximum trap-arm developed length (meters) by trap + * size: 30" @ 1¼", 42" @ 1½", 60" @ 2", 72" @ 3", 120" @ 4". */ +const TRAP_ARM_MAX_M: ReadonlyArray = [ + [1.25, 30 * 0.0254], + [1.5, 42 * 0.0254], + [2, 60 * 0.0254], + [3, 72 * 0.0254], + [4, 120 * 0.0254], +] + +function trapArmMaxFor(diameterIn: number): number { + let max = Infinity + for (const [size, lengthM] of TRAP_ARM_MAX_M) { + if (diameterIn <= size) return lengthM + max = lengthM + } + return max +} + +/** Slopes shallower than this fraction of the minimum are flagged + * "too flat" — a small tolerance keeps round-off off the list. */ +const SLOPE_TOLERANCE = 0.9 +/** Horizontal legs shorter than this (meters) are treated as vertical + * stacks and skipped from the slope check. */ +const VERTICAL_LEG_EPS_M = 0.02 + +type Vec3 = readonly [number, number, number] + +function legSlope(a: Vec3, b: Vec3): { horizontalM: number; slope: number } { + const horizontalM = Math.hypot(b[0] - a[0], b[2] - a[2]) + if (horizontalM < VERTICAL_LEG_EPS_M) return { horizontalM, slope: Infinity } + return { horizontalM, slope: Math.abs(a[1] - b[1]) / horizontalM } +} + +function inchLabel(value: number): string { + return `${value}"` +} + +/** Per-foot slope as a readable fraction, e.g. 0.0208 → '¼"/ft'. */ +function slopePerFootLabel(slope: number): string { + const inchesPerFoot = slope * 12 + return `${inchesPerFoot.toFixed(2)}"/ft` +} + +/** + * Run every DWV rule over the scene and return the findings. Empty + * array = nothing to flag. Pure: no scene/store access, no rendering. + */ +export function validateDwv(nodes: Readonly>): DwvFinding[] { + const findings: DwvFinding[] = [] + + // ── Per-segment slope (waste only) ────────────────────────────── + for (const node of Object.values(nodes)) { + if (node?.type !== 'pipe-segment' || node.system !== 'waste') continue + const path = node.path as Vec3[] + const minSlope = minSlopeFor(node.diameter) + const maxSlope = node.diameter / 12 // 1 pipe-diameter per foot → siphoning + let flaggedFlat = false + let flaggedSteep = false + for (let i = 0; i < path.length - 1; i++) { + const { slope } = legSlope(path[i]!, path[i + 1]!) + if (slope === Infinity) continue // vertical stack leg + if (!flaggedFlat && slope < minSlope * SLOPE_TOLERANCE) { + findings.push({ + severity: 'error', + code: 'slope-too-flat', + message: `${inchLabel(node.diameter)} drain slopes ${slopePerFootLabel( + slope, + )} — IPC 704.1 requires at least ${slopePerFootLabel(minSlope)}.`, + nodeIds: [node.id], + }) + flaggedFlat = true + } + if (!flaggedSteep && slope > maxSlope) { + findings.push({ + severity: 'warning', + code: 'slope-too-steep', + message: `${inchLabel(node.diameter)} drain slopes ${slopePerFootLabel( + slope, + )} — over one pipe-diameter per foot risks siphoning the traps.`, + nodeIds: [node.id], + }) + flaggedSteep = true + } + } + } + + // ── Component-scoped trap rules ────────────────────────────────── + for (const component of buildPortComponents(nodes)) { + const traps: AnyNode[] = [] + + for (const id of component) { + const node = nodes[id] + if (!node) continue + if (node.type === 'pipe-trap') { + traps.push(node) + } + } + + // Trap-arm developed length: trap outlet → its vent, capped by size. + // Independent of waste segments — a trap on its own can already be + // over-armed. + for (const trap of traps) { + const t = trap as { id: AnyNodeId; diameter: number; armLengthM?: number } + const armLengthM = t.armLengthM ?? 0 + const maxArm = trapArmMaxFor(t.diameter) + if (armLengthM > maxArm + 1e-6) { + findings.push({ + severity: 'error', + code: 'trap-arm-too-long', + message: `${inchLabel(t.diameter)} trap arm runs ${(armLengthM / 0.0254).toFixed( + 0, + )}" to its vent — IPC 909.1 caps it at ${(maxArm / 0.0254).toFixed(0)}".`, + nodeIds: [t.id], + }) + } + } + } + + return findings +} diff --git a/services/pascal-editor/packages/core/src/solar-panel-presets.ts b/services/pascal-editor/packages/core/src/solar-panel-presets.ts new file mode 100644 index 000000000..449183c8d --- /dev/null +++ b/services/pascal-editor/packages/core/src/solar-panel-presets.ts @@ -0,0 +1,55 @@ +import { z } from 'zod' + +// Vendor-neutral visual presets. Picking one writes the four `panelWidth`, +// `panelHeight`, `frameThickness`, `frameDepth` fields onto a SolarPanelNode. +// Manually editing any of those four fields clears the preset back to undefined +// ("Custom") so the schema's `panelTypePreset` always matches the dims. + +export const SolarPanelPresetKey = z.enum([ + 'residential', + 'residential-large', + 'compact', + 'frameless', +]) +export type SolarPanelPresetKey = z.infer + +export type SolarPanelPresetDims = { + panelWidth: number + panelHeight: number + frameThickness: number + frameDepth: number +} + +export const SOLAR_PANEL_PRESETS: Record = { + residential: { + panelWidth: 1.0, + panelHeight: 1.65, + frameThickness: 0.04, + frameDepth: 0.04, + }, + 'residential-large': { + panelWidth: 1.0, + panelHeight: 2.0, + frameThickness: 0.04, + frameDepth: 0.04, + }, + compact: { + panelWidth: 0.83, + panelHeight: 1.0, + frameThickness: 0.03, + frameDepth: 0.035, + }, + frameless: { + panelWidth: 1.0, + panelHeight: 1.65, + frameThickness: 0.005, + frameDepth: 0.025, + }, +} + +export const SOLAR_PANEL_PRESET_LABELS: Record = { + residential: 'Residential', + 'residential-large': 'Residential Large', + compact: 'Compact', + frameless: 'Frameless', +} diff --git a/services/pascal-editor/packages/core/src/store/actions/node-actions.ts b/services/pascal-editor/packages/core/src/store/actions/node-actions.ts new file mode 100644 index 000000000..bc7b524a4 --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/actions/node-actions.ts @@ -0,0 +1,1182 @@ +import { nodeRegistry } from '../../registry/registry' +import { + type AnyNode, + type AnyNodeId, + AnyNode as AnyNodeSchema, + createDefaultRidgeVentsForSegment, + getEffectiveWallSurfaceMaterial, + getWallSurfaceMaterialSignature, + isAutoRidgeVentEnabled, + isDefaultRidgeVentNode, + type RoofSegmentNode, + type WallNode, +} from '../../schema' +import type { CollectionId } from '../../schema/collections' +import type { SceneState } from '../use-scene' + +type AnyContainerNode = AnyNode & { children: string[] } +type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId } +type NodeUpdateOp = { id: AnyNodeId; data: Partial } +type NodeDeleteOp = AnyNodeId +type WallAttachmentUpdate = { id: AnyNodeId; data: Partial } +type WallMergePlan = { + primaryWallId: AnyNodeId + secondaryWallId: AnyNodeId + mergedStart: [number, number] + mergedEnd: [number, number] + mergedChildren: WallNode['children'] + attachmentUpdates: WallAttachmentUpdate[] +} + +const DEFAULT_RIDGE_VENT_REFRESH_FIELDS = new Set([ + 'roofType', + 'width', + 'depth', + 'pitch', + 'overhang', + 'wallThickness', + 'shingleThickness', + 'gambrelLowerWidthRatio', + 'mansardSteepWidthRatio', + 'dutchHipWidthRatio', + 'dutchWaistLengthRatio', + 'dutchGabletRake', +]) + +type ZodCheckLike = { + _zod?: { + def?: { + check?: string + value?: unknown + inclusive?: boolean + format?: string + } + } +} + +type ZodSchemaDefLike = { + type?: string + innerType?: ZodSchemaLike + shape?: Record + options?: readonly ZodSchemaLike[] + items?: readonly ZodSchemaLike[] + rest?: ZodSchemaLike | null + element?: ZodSchemaLike + checks?: readonly ZodCheckLike[] + defaultValue?: unknown + values?: readonly unknown[] + entries?: Record +} + +type ZodSchemaLike = { + _zod?: { + def?: ZodSchemaDefLike + bag?: { + minimum?: number + maximum?: number + exclusiveMinimum?: number + exclusiveMaximum?: number + } + values?: Set + } + def?: ZodSchemaDefLike + shape?: Record + minValue?: number | null + maxValue?: number | null +} + +type NumericLimit = { + value: number + inclusive: boolean +} + +type NumericConstraints = { + min?: NumericLimit + max?: NumericLimit + integer: boolean +} + +type NumericSanitizeIssue = { + path: PropertyKey[] + from: number + to?: number + action: 'clamped' | 'dropped' | 'rounded' +} + +type NumericSanitizeResult = { + value: unknown + issues: NumericSanitizeIssue[] + omit?: boolean +} + +const NUMBER_FORMAT_BOUNDS: Record = { + safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], + int32: [-2147483648, 2147483647], + uint32: [0, 4294967295], + float32: [-3.4028234663852886e38, 3.4028234663852886e38], +} + +const INTEGER_NUMBER_FORMATS = new Set(['safeint', 'int32', 'uint32']) + +function getSchemaDef(schema: ZodSchemaLike | null | undefined): ZodSchemaDefLike | undefined { + return schema?._zod?.def ?? schema?.def +} + +function getSchemaDefault(schema: ZodSchemaLike): unknown { + const def = getSchemaDef(schema) + if (!(def?.type === 'default' || def?.type === 'prefault')) return undefined + return def.defaultValue +} + +function unwrapSchema(schema: ZodSchemaLike | null | undefined): ZodSchemaLike | null { + let current = schema ?? null + while (current) { + const def = getSchemaDef(current) + if ( + !( + def?.type === 'default' || + def?.type === 'prefault' || + def?.type === 'optional' || + def?.type === 'nullable' || + def?.type === 'catch' || + def?.type === 'readonly' || + def?.type === 'nonoptional' + ) + ) { + return current + } + current = def.innerType ?? null + } + + return null +} + +function getObjectShape(schema: ZodSchemaLike | null | undefined) { + const unwrapped = unwrapSchema(schema) + const def = getSchemaDef(unwrapped) + if (def?.type !== 'object') return null + + return unwrapped?.shape ?? def.shape ?? null +} + +function schemaAllowsValue(schema: ZodSchemaLike | null | undefined, value: unknown): boolean { + const unwrapped = unwrapSchema(schema) + if (!unwrapped) return false + + const def = getSchemaDef(unwrapped) + if (unwrapped._zod?.values?.has(value)) return true + if (Array.isArray(def?.values) && def.values.includes(value)) return true + if (def?.entries && Object.values(def.entries).includes(value)) return true + + return false +} + +function getNodeSchemaForType(type: unknown): ZodSchemaLike | null { + const schema = AnyNodeSchema as unknown as ZodSchemaLike + const options = getSchemaDef(schema)?.options + if (!options) return null + + for (const option of options) { + const shape = getObjectShape(option) + if (shape?.type && schemaAllowsValue(shape.type, type)) { + return option + } + } + + return null +} + +function applyLowerLimit(current: NumericLimit | undefined, candidate: NumericLimit): NumericLimit { + if (!current) return candidate + if (candidate.value > current.value) return candidate + if (candidate.value === current.value && !candidate.inclusive) return candidate + return current +} + +function applyUpperLimit(current: NumericLimit | undefined, candidate: NumericLimit): NumericLimit { + if (!current) return candidate + if (candidate.value < current.value) return candidate + if (candidate.value === current.value && !candidate.inclusive) return candidate + return current +} + +function getNumberConstraints(schema: ZodSchemaLike): NumericConstraints { + const unwrapped = unwrapSchema(schema) ?? schema + const def = getSchemaDef(unwrapped) + const constraints: NumericConstraints = { integer: false } + + const minValue = unwrapped.minValue + if (typeof minValue === 'number') { + constraints.min = applyLowerLimit(constraints.min, { value: minValue, inclusive: true }) + } + + const maxValue = unwrapped.maxValue + if (typeof maxValue === 'number') { + constraints.max = applyUpperLimit(constraints.max, { value: maxValue, inclusive: true }) + } + + for (const check of def?.checks ?? []) { + const checkDef = check._zod?.def + if (!checkDef) continue + + if (checkDef.check === 'greater_than' && typeof checkDef.value === 'number') { + constraints.min = applyLowerLimit(constraints.min, { + value: checkDef.value, + inclusive: checkDef.inclusive !== false, + }) + } else if (checkDef.check === 'less_than' && typeof checkDef.value === 'number') { + constraints.max = applyUpperLimit(constraints.max, { + value: checkDef.value, + inclusive: checkDef.inclusive !== false, + }) + } else if (checkDef.check === 'number_format' && checkDef.format) { + constraints.integer ||= INTEGER_NUMBER_FORMATS.has(checkDef.format) + const bounds = NUMBER_FORMAT_BOUNDS[checkDef.format] + if (bounds) { + constraints.min = applyLowerLimit(constraints.min, { + value: bounds[0], + inclusive: true, + }) + constraints.max = applyUpperLimit(constraints.max, { + value: bounds[1], + inclusive: true, + }) + } + } + } + + const bag = unwrapped._zod?.bag + if (typeof bag?.minimum === 'number') { + constraints.min = applyLowerLimit(constraints.min, { value: bag.minimum, inclusive: true }) + } + if (typeof bag?.exclusiveMinimum === 'number') { + constraints.min = applyLowerLimit(constraints.min, { + value: bag.exclusiveMinimum, + inclusive: false, + }) + } + if (typeof bag?.maximum === 'number') { + constraints.max = applyUpperLimit(constraints.max, { value: bag.maximum, inclusive: true }) + } + if (typeof bag?.exclusiveMaximum === 'number') { + constraints.max = applyUpperLimit(constraints.max, { + value: bag.exclusiveMaximum, + inclusive: false, + }) + } + + return constraints +} + +function nextAbove(value: number) { + return value === 0 ? Number.EPSILON : value + Math.abs(value) * Number.EPSILON +} + +function nextBelow(value: number) { + return value === 0 ? -Number.EPSILON : value - Math.abs(value) * Number.EPSILON +} + +function clampNumber(value: number, constraints: NumericConstraints) { + let next = value + let rounded = false + let clamped = false + + if (constraints.integer && !Number.isInteger(next)) { + next = Math.round(next) + rounded = true + } + + if (constraints.min) { + const min = constraints.min.inclusive ? constraints.min.value : nextAbove(constraints.min.value) + if (next < min) { + next = min + clamped = true + } + } + + if (constraints.max) { + const max = constraints.max.inclusive ? constraints.max.value : nextBelow(constraints.max.value) + if (next > max) { + next = max + clamped = true + } + } + + return { + value: next, + action: clamped ? 'clamped' : rounded ? 'rounded' : undefined, + } satisfies { value: number; action?: NumericSanitizeIssue['action'] } +} + +function getFiniteFallbackNumber(fallback: unknown, constraints: NumericConstraints) { + if (typeof fallback !== 'number' || !Number.isFinite(fallback)) return undefined + return clampNumber(fallback, constraints).value +} + +function sanitizeNumber( + schema: ZodSchemaLike | null, + value: number, + fallback: unknown, + path: PropertyKey[], +): NumericSanitizeResult { + const constraints = schema ? getNumberConstraints(schema) : { integer: false } + + if (!Number.isFinite(value)) { + const replacement = getFiniteFallbackNumber(fallback, constraints) + if (replacement === undefined) { + return { + value, + omit: true, + issues: [{ path, from: value, action: 'dropped' }], + } + } + + return { + value: replacement, + issues: [{ path, from: value, to: replacement, action: 'dropped' }], + } + } + + const clamped = clampNumber(value, constraints) + if (!Object.is(clamped.value, value)) { + return { + value: clamped.value, + issues: [ + { + path, + from: value, + to: clamped.value, + action: clamped.action ?? 'clamped', + }, + ], + } + } + + return { value, issues: [] } +} + +function sanitizeNumericValue( + schema: ZodSchemaLike | null, + value: unknown, + fallback: unknown, + path: PropertyKey[], +): NumericSanitizeResult { + const defaultFallback = schema ? getSchemaDefault(schema) : undefined + const effectiveFallback = fallback === undefined ? defaultFallback : fallback + const unwrapped = unwrapSchema(schema) + const def = getSchemaDef(unwrapped) + + if (def?.type === 'number') { + if (typeof value !== 'number') return { value, issues: [] } + return sanitizeNumber(unwrapped, value, effectiveFallback, path) + } + + if (typeof value === 'number') { + return sanitizeNumber(null, value, effectiveFallback, path) + } + + if (def?.type === 'tuple' && Array.isArray(value)) { + const fallbackItems = Array.isArray(effectiveFallback) ? effectiveFallback : [] + const next = [...value] + const issues: NumericSanitizeIssue[] = [] + + for (let index = 0; index < next.length; index += 1) { + const itemSchema = def.items?.[index] ?? def.rest ?? null + const child = sanitizeNumericValue(itemSchema, next[index], fallbackItems[index], [ + ...path, + index, + ]) + issues.push(...child.issues) + + if (child.omit) { + return { value, omit: true, issues } + } + + next[index] = child.value + } + + return { value: issues.length > 0 ? next : value, issues } + } + + if (def?.type === 'array' && Array.isArray(value)) { + const fallbackItems = Array.isArray(effectiveFallback) ? effectiveFallback : [] + const next: unknown[] = [] + const issues: NumericSanitizeIssue[] = [] + let omitted = false + + for (let index = 0; index < value.length; index += 1) { + const child = sanitizeNumericValue(def.element ?? null, value[index], fallbackItems[index], [ + ...path, + index, + ]) + issues.push(...child.issues) + if (child.omit) { + omitted = true + continue + } + next.push(child.value) + } + + return { value: issues.length > 0 || omitted ? next : value, issues } + } + + if (value && typeof value === 'object' && !Array.isArray(value)) { + const shape = def?.type === 'object' ? (unwrapped?.shape ?? def.shape ?? {}) : {} + const fallbackObject = + effectiveFallback && + typeof effectiveFallback === 'object' && + !Array.isArray(effectiveFallback) + ? (effectiveFallback as Record) + : {} + const input = value as Record + const next: Record = { ...input } + const issues: NumericSanitizeIssue[] = [] + + for (const key of Object.keys(input)) { + const child = sanitizeNumericValue(shape[key] ?? null, input[key], fallbackObject[key], [ + ...path, + key, + ]) + issues.push(...child.issues) + + if (child.omit) { + delete next[key] + } else { + next[key] = child.value + } + } + + return { value: issues.length > 0 ? next : value, issues } + } + + return { value, issues: [] } +} + +function formatNumericValue(value: number) { + if (Number.isNaN(value)) return 'NaN' + if (value === Infinity) return 'Infinity' + if (value === -Infinity) return '-Infinity' + return String(value) +} + +function numericSanitizeIssuesToMessage(issues: NumericSanitizeIssue[]): string { + return issues + .map((issue) => { + const path = issue.path.map(String).join('.') || '' + const to = issue.to === undefined ? '' : ` -> ${formatNumericValue(issue.to)}` + return `${path}: ${formatNumericValue(issue.from)} ${issue.action}${to}` + }) + .join('; ') +} + +function warnSanitizedNodeMutation( + mutation: 'create' | 'update', + nodeId: AnyNodeId, + issues: NumericSanitizeIssue[], +) { + console.warn( + `[Scene] Sanitized invalid numeric node ${mutation}`, + nodeId, + numericSanitizeIssuesToMessage(issues), + ) +} + +function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode { + const candidate = { ...node, parentId } + const parsed = AnyNodeSchema.safeParse(candidate) + if (parsed.success) return parsed.data + + const schema = getNodeSchemaForType(candidate.type) + const sanitized = sanitizeNumericValue(schema, candidate, undefined, []) + + if (sanitized.issues.length === 0) { + return candidate as AnyNode + } + + warnSanitizedNodeMutation('create', node.id, sanitized.issues) + + return sanitized.value as AnyNode +} + +function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode { + const candidate = { ...currentNode, ...data } + const parsed = AnyNodeSchema.safeParse(candidate) + if (parsed.success) return parsed.data + + const schema = getNodeSchemaForType(candidate.type) + const sanitized = sanitizeNumericValue(schema, data, currentNode, []) + + if (sanitized.issues.length === 0) { + return candidate as AnyNode + } + + warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues) + + return { ...currentNode, ...(sanitized.value as Partial) } as AnyNode +} + +function shouldRefreshDefaultRidgeVents(data: Partial) { + return Object.keys(data).some((key) => DEFAULT_RIDGE_VENT_REFRESH_FIELDS.has(key)) +} + +function refreshDefaultRidgeVentsForSegment( + nextNodes: Record, + segment: RoofSegmentNode, +): AnyNodeId[] { + const childIds = Array.isArray(segment.children) ? (segment.children as AnyNodeId[]) : [] + if (!isAutoRidgeVentEnabled(segment, nextNodes)) return [] + + const defaultIds = childIds.filter((childId) => + isDefaultRidgeVentNode(nextNodes[childId], segment.id), + ) + const defaultIdSet = new Set(defaultIds) + for (const id of defaultIds) { + delete nextNodes[id] + } + + const nextVents = createDefaultRidgeVentsForSegment(segment) + for (const vent of nextVents) { + nextNodes[vent.id as AnyNodeId] = { + ...vent, + parentId: segment.id, + } as AnyNode + } + + nextNodes[segment.id as AnyNodeId] = { + ...segment, + children: [ + ...childIds.filter((childId) => !defaultIdSet.has(childId)), + ...nextVents.map((vent) => vent.id as AnyNodeId), + ], + } as AnyNode + + return nextVents.map((vent) => vent.id as AnyNodeId) +} + +// Track pending RAF for updateNodesAction to prevent multiple queued callbacks +let pendingRafId: number | null = null +let pendingUpdates: Set = new Set() + +function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz <= tolerance * tolerance +} + +function wallLength(wall: Pick) { + return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) +} + +function getWallEndpointAtPoint( + wall: Pick, + point: [number, number], +): 'start' | 'end' | null { + if (pointsEqual(wall.start, point)) return 'start' + if (pointsEqual(wall.end, point)) return 'end' + return null +} + +function getWallFreeEndpoint(wall: Pick, sharedPoint: [number, number]) { + return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start +} + +function areWallStylesCompatible(a: WallNode, b: WallNode) { + const aInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'interior')) + const bInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'interior')) + const aExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'exterior')) + const bExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'exterior')) + + return ( + (a.parentId ?? null) === (b.parentId ?? null) && + Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && + Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && + Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && + aInterior === bInterior && + aExterior === bExterior && + a.frontSide === b.frontSide && + a.backSide === b.backSide && + a.visible === b.visible + ) +} + +function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) { + const freeA = getWallFreeEndpoint(a, sharedPoint) + const freeB = getWallFreeEndpoint(b, sharedPoint) + const ax = freeA[0] - sharedPoint[0] + const az = freeA[1] - sharedPoint[1] + const bx = freeB[0] - sharedPoint[0] + const bz = freeB[1] - sharedPoint[1] + const lenA = Math.hypot(ax, az) + const lenB = Math.hypot(bx, bz) + + if (lenA < 1e-6 || lenB < 1e-6) return false + + const cross = (ax * bz - az * bx) / (lenA * lenB) + const dot = (ax * bx + az * bz) / (lenA * lenB) + return Math.abs(cross) <= 1e-4 && dot < -0.999 +} + +function resolveMergedWallEndpoints( + primary: WallNode, + secondary: WallNode, + sharedPoint: [number, number], +): { start: [number, number]; end: [number, number] } { + const primaryEndpoint = getWallEndpointAtPoint(primary, sharedPoint) + const secondaryEndpoint = getWallEndpointAtPoint(secondary, sharedPoint) + + if (primaryEndpoint === 'end' && secondaryEndpoint === 'start') { + return { start: primary.start, end: secondary.end } + } + if (primaryEndpoint === 'start' && secondaryEndpoint === 'end') { + return { start: secondary.start, end: primary.end } + } + if (primaryEndpoint === 'start' && secondaryEndpoint === 'start') { + return { start: primary.end, end: secondary.end } + } + + return { start: primary.start, end: secondary.start } +} + +function buildMergedWallAttachmentUpdates( + primary: WallNode, + secondary: WallNode, + mergedWallId: AnyNodeId, + mergedStart: [number, number], + mergedEnd: [number, number], + nodes: Record, +): WallAttachmentUpdate[] { + const mergedLength = Math.max( + Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), + 1e-6, + ) + const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength + const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength + const updates: WallAttachmentUpdate[] = [] + + const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[] + for (const childId of wallChildren) { + const child = nodes[childId] + if (!(child && 'position' in child && Array.isArray(child.position))) { + continue + } + + const sourceWall = child.parentId === secondary.id ? secondary : primary + const sourceLength = Math.max(wallLength(sourceWall), 1e-6) + const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 + const worldX = + sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength + const worldZ = + sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength + const nextLocalX = Math.max( + 0, + Math.min( + mergedLength, + (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ, + ), + ) + + updates.push({ + id: childId, + data: { + parentId: mergedWallId, + wallId: mergedWallId, + position: [nextLocalX, child.position[1], child.position[2]] as typeof child.position, + ...('wallT' in child ? { wallT: nextLocalX / mergedLength } : {}), + } as Partial, + }) + } + + return updates +} + +function buildWallMergePlans( + nodes: Record, + idsToDelete: AnyNodeId[], +): WallMergePlan[] { + const deletedWalls = idsToDelete + .map((id) => nodes[id]) + .filter((node): node is WallNode => node?.type === 'wall') + const skippedWallIds = new Set(idsToDelete) + const usedWallIds = new Set() + const mergePlans: WallMergePlan[] = [] + + for (const deletedWall of deletedWalls) { + const junctions: Array<[number, number]> = [deletedWall.start, deletedWall.end] + + for (const junction of junctions) { + const candidates = Object.values(nodes).filter((node): node is WallNode => { + if (node?.type !== 'wall') return false + if (skippedWallIds.has(node.id) || usedWallIds.has(node.id)) return false + if ((node.parentId ?? null) !== (deletedWall.parentId ?? null)) return false + return pointsEqual(node.start, junction) || pointsEqual(node.end, junction) + }) + + if (candidates.length !== 2) { + continue + } + + const sortedCandidates = [...candidates].sort((a, b) => { + const attachmentDiff = (b.children?.length ?? 0) - (a.children?.length ?? 0) + if (attachmentDiff !== 0) { + return attachmentDiff + } + return a.id.localeCompare(b.id) + }) + const [primary, secondary] = sortedCandidates + if ( + !( + primary && + secondary && + areWallStylesCompatible(primary, secondary) && + areWallsCollinearAcrossPoint(primary, secondary, junction) + ) + ) { + continue + } + + const { start, end } = resolveMergedWallEndpoints(primary, secondary, junction) + const mergedChildren = Array.from( + new Set([...(primary.children ?? []), ...(secondary.children ?? [])]), + ) as WallNode['children'] + const attachmentUpdates = buildMergedWallAttachmentUpdates( + primary, + secondary, + primary.id, + start, + end, + nodes, + ) + + mergePlans.push({ + primaryWallId: primary.id, + secondaryWallId: secondary.id, + mergedStart: start, + mergedEnd: end, + mergedChildren, + attachmentUpdates, + }) + usedWallIds.add(primary.id) + usedWallIds.add(secondary.id) + } + } + + return mergePlans +} + +export const createNodesAction = ( + set: (fn: (state: SceneState) => Partial) => void, + get: () => SceneState, + ops: NodeCreateOp[], +) => { + if (get().readOnly) return + set((state) => { + const nextNodes = { ...state.nodes } + const nextRootIds = [...state.rootNodeIds] + + for (const { node, parentId } of ops) { + const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null + + const newNode = parseCreatedNode(node, effectiveParentId) + + nextNodes[newNode.id] = newNode + + // 2. Update the Parent's children list. We append to ANY container + // parent (kind has `children` in its schema) — if the field is + // present but undefined (e.g. an old saved scene from before the + // kind gained children), we initialise to `[]` first so the + // reparenting goes through. Without this, hosting items on an + // old shelf (v1, before `children` was added) silently no-ops: + // the item is reparented to the shelf but the shelf's children + // array is never updated, so `ParametricNodeRenderer` doesn't + // mount it and the item "disappears". + if (effectiveParentId && nextNodes[effectiveParentId]) { + const parent = nextNodes[effectiveParentId] + if ('children' in parent) { + const existing = (parent as { children?: unknown }).children + const children = Array.isArray(existing) ? (existing as AnyNodeId[]) : [] + nextNodes[effectiveParentId] = { + ...parent, + children: Array.from(new Set([...children, newNode.id])) as any, + } + } + } else if (!effectiveParentId) { + // 3. Handle Root nodes + if (!nextRootIds.includes(newNode.id)) { + nextRootIds.push(newNode.id) + } + } + } + + return { nodes: nextNodes, rootNodeIds: nextRootIds } + }) + + // 4. System Sync + ops.forEach(({ node, parentId }) => { + get().markDirty(node.id) + if (parentId) get().markDirty(parentId) + else if (node.parentId) get().markDirty(node.parentId as AnyNodeId) + }) +} + +export const applyNodeChangesAction = ( + set: (fn: (state: SceneState) => Partial) => void, + get: () => SceneState, + changes: { create?: NodeCreateOp[]; update?: NodeUpdateOp[]; delete?: NodeDeleteOp[] }, +) => { + if (get().readOnly) return + + const createOps = changes.create ?? [] + const updateOps = changes.update ?? [] + const deleteOps = changes.delete ?? [] + const nodesToMarkDirty = new Set() + const parentsToMarkDirty = new Set() + + set((state) => { + const nextNodes = { ...state.nodes } + const nextCollections = { ...state.collections } + const nextRootIds = [...state.rootNodeIds] + let resolvedRootIds = nextRootIds + + for (const { id, data } of updateOps) { + const currentNode = nextNodes[id] + if (!currentNode) continue + const updatedNode = parseUpdatedNode(currentNode, data) + + if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { + const oldParentId = currentNode.parentId as AnyNodeId | null + if (oldParentId && nextNodes[oldParentId]) { + const oldParent = nextNodes[oldParentId] as AnyContainerNode + nextNodes[oldParent.id] = { + ...oldParent, + children: oldParent.children.filter((childId) => childId !== id), + } as AnyNode + parentsToMarkDirty.add(oldParent.id) + } + + const newParentId = data.parentId as AnyNodeId | null + if (newParentId && nextNodes[newParentId]) { + const newParent = nextNodes[newParentId] as AnyContainerNode + nextNodes[newParent.id] = { + ...newParent, + children: Array.from(new Set([...newParent.children, id])), + } as AnyNode + parentsToMarkDirty.add(newParent.id) + } + } + + nextNodes[id] = updatedNode + if (updatedNode.type === 'roof-segment' && shouldRefreshDefaultRidgeVents(data)) { + for (const ventId of refreshDefaultRidgeVentsForSegment(nextNodes, updatedNode)) { + nodesToMarkDirty.add(ventId) + } + } + nodesToMarkDirty.add(id) + } + + for (const { node, parentId } of createOps) { + const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null + const newNode = parseCreatedNode(node, effectiveParentId) + + nextNodes[newNode.id as AnyNodeId] = newNode + nodesToMarkDirty.add(newNode.id as AnyNodeId) + + if (effectiveParentId && nextNodes[effectiveParentId]) { + const parent = nextNodes[effectiveParentId] + if ('children' in parent && Array.isArray(parent.children)) { + nextNodes[effectiveParentId] = { + ...parent, + children: Array.from(new Set([...parent.children, newNode.id])) as any, + } + parentsToMarkDirty.add(effectiveParentId) + } + } else if (!effectiveParentId && !nextRootIds.includes(newNode.id as AnyNodeId)) { + nextRootIds.push(newNode.id as AnyNodeId) + } + } + + const allIdsToDelete = new Set() + const collectDelete = (id: AnyNodeId) => { + if (allIdsToDelete.has(id)) return + allIdsToDelete.add(id) + const node = nextNodes[id] + if (node && 'children' in node && Array.isArray(node.children)) { + for (const childId of node.children) { + collectDelete(childId as AnyNodeId) + } + } + } + + for (const id of deleteOps) { + collectDelete(id) + } + + for (const id of allIdsToDelete) { + const node = nextNodes[id] + if (!node) continue + + const parentId = node.parentId as AnyNodeId | null + if (parentId && nextNodes[parentId] && !allIdsToDelete.has(parentId)) { + const parent = nextNodes[parentId] as AnyContainerNode + if (parent.children) { + nextNodes[parent.id] = { + ...parent, + children: parent.children.filter((childId) => childId !== id), + } as AnyNode + parentsToMarkDirty.add(parent.id) + } + } + + resolvedRootIds = resolvedRootIds.filter((rootId) => rootId !== id) + + if ('collectionIds' in node && node.collectionIds) { + for (const collectionId of node.collectionIds as CollectionId[]) { + const collection = nextCollections[collectionId] + if (collection) { + nextCollections[collectionId] = { + ...collection, + nodeIds: collection.nodeIds.filter((nodeId) => nodeId !== id), + } + } + } + } + + delete nextNodes[id] + } + + return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections } + }) + + for (const id of nodesToMarkDirty) { + get().markDirty(id) + } + for (const id of parentsToMarkDirty) { + get().markDirty(id) + const parent = get().nodes[id] + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) { + get().markDirty(childId as AnyNodeId) + } + } + } +} + +export const updateNodesAction = ( + set: (fn: (state: SceneState) => Partial) => void, + get: () => SceneState, + updates: { id: AnyNodeId; data: Partial }[], +) => { + if (get().readOnly) return + const parentsToUpdate = new Set() + const extraNodesToUpdate = new Set() + + set((state) => { + const nextNodes = { ...state.nodes } + + for (const { id, data } of updates) { + const currentNode = nextNodes[id] + if (!currentNode) continue + const updatedNode = parseUpdatedNode(currentNode, data) + + // Handle Reparenting Logic + if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { + // 1. Remove from old parent + const oldParentId = currentNode.parentId as AnyNodeId | null + if (oldParentId && nextNodes[oldParentId]) { + const oldParent = nextNodes[oldParentId] as AnyContainerNode + const oldChildren = Array.isArray((oldParent as { children?: unknown }).children) + ? (oldParent as { children: AnyNodeId[] }).children + : [] + nextNodes[oldParent.id] = { + ...oldParent, + children: oldChildren.filter((childId) => childId !== id), + } as AnyNode + parentsToUpdate.add(oldParent.id) + } + + // 2. Add to new parent. Defensive against parents that don't yet + // carry a `children` array — older saved scenes can predate the + // schema field on a particular kind (shelf v1 → v2 added one), + // and a spread of `undefined` here throws and aborts the entire + // `set` callback. Initialising to `[]` matches what the schema's + // default would have produced. + const newParentId = data.parentId as AnyNodeId | null + if (newParentId && nextNodes[newParentId]) { + const newParent = nextNodes[newParentId] as AnyContainerNode + const newChildren = Array.isArray((newParent as { children?: unknown }).children) + ? (newParent as { children: AnyNodeId[] }).children + : [] + nextNodes[newParent.id] = { + ...newParent, + children: Array.from(new Set([...newChildren, id])), + } as AnyNode + parentsToUpdate.add(newParent.id) + } + } + + // Apply the update + nextNodes[id] = updatedNode + if (updatedNode.type === 'roof-segment' && shouldRefreshDefaultRidgeVents(data)) { + for (const ventId of refreshDefaultRidgeVentsForSegment(nextNodes, updatedNode)) { + extraNodesToUpdate.add(ventId) + } + } + } + + return { nodes: nextNodes } + }) + + // Batch dirty-marking into a single RAF to avoid redundant callbacks during rapid updates + for (const u of updates) { + pendingUpdates.add(u.id) + } + for (const pId of parentsToUpdate) { + pendingUpdates.add(pId) + } + for (const id of extraNodesToUpdate) { + pendingUpdates.add(id) + } + + if (pendingRafId !== null) { + cancelAnimationFrame(pendingRafId) + } + + pendingRafId = requestAnimationFrame(() => { + pendingUpdates.forEach((id) => { + get().markDirty(id) + }) + pendingUpdates.clear() + pendingRafId = null + }) +} + +export const deleteNodesAction = ( + set: (fn: (state: SceneState) => Partial) => void, + get: () => SceneState, + ids: AnyNodeId[], +) => { + if (get().readOnly) return + const parentsToMarkDirty = new Set() + const nodesToMarkDirty = new Set() + const deletedIds = new Set() + const mergePlans = buildWallMergePlans(get().nodes, ids) + + set((state) => { + const nextNodes = { ...state.nodes } + const nextCollections = { ...state.collections } + let nextRootIds = [...state.rootNodeIds] + + // Collect all ids to delete (the requested ids + all their descendants) before + // mutating anything, so the recursive walk reads consistent state. + const allIds = new Set() + const collect = (id: AnyNodeId) => { + if (allIds.has(id)) return + allIds.add(id) + const node = nextNodes[id] + if (node && 'children' in node) { + for (const cid of node.children as AnyNodeId[]) collect(cid) + } + } + for (const id of ids) collect(id) + for (const plan of mergePlans) { + allIds.add(plan.secondaryWallId) + } + for (const id of allIds) deletedIds.add(id) + + // Let each deleted kind undo what it imposed on its neighbours (e.g. an + // auto-inserted elbow re-extends the duct runs it trimmed back onto the + // corner it replaced). Read against pre-deletion `nextNodes`; skip + // patches that target a node also being deleted. + for (const id of allIds) { + const node = nextNodes[id] + if (!node) continue + const onDelete = nodeRegistry.get(node.type)?.parametrics?.onDelete + if (!onDelete) continue + for (const { id: targetId, data } of onDelete(node, nextNodes)) { + if (allIds.has(targetId)) continue + const target = nextNodes[targetId] + if (!target) continue + nextNodes[targetId] = { ...target, ...data } as AnyNode + nodesToMarkDirty.add(targetId) + } + } + + for (const plan of mergePlans) { + const primaryWall = nextNodes[plan.primaryWallId] + if (!(primaryWall && primaryWall.type === 'wall') || allIds.has(plan.primaryWallId)) { + continue + } + + nextNodes[plan.primaryWallId] = { + ...primaryWall, + start: plan.mergedStart, + end: plan.mergedEnd, + children: plan.mergedChildren, + } + nodesToMarkDirty.add(plan.primaryWallId) + + for (const update of plan.attachmentUpdates) { + if (allIds.has(update.id)) continue + const child = nextNodes[update.id] + if (!child) continue + nextNodes[update.id] = { ...child, ...update.data } as AnyNode + nodesToMarkDirty.add(update.id) + } + } + + for (const id of allIds) { + const node = nextNodes[id] + if (!node) continue + + // 1. Remove reference from parent — only if the parent itself is NOT also being deleted + const parentId = node.parentId as AnyNodeId | null + if (parentId && nextNodes[parentId] && !allIds.has(parentId)) { + const parent = nextNodes[parentId] as AnyContainerNode + if (parent.children) { + nextNodes[parent.id] = { + ...parent, + children: parent.children.filter((cid) => cid !== id), + } as AnyNode + parentsToMarkDirty.add(parent.id) + } + } + + // 2. Remove from root list + nextRootIds = nextRootIds.filter((rid) => rid !== id) + + // 3. Remove from any collections it belongs to + if ('collectionIds' in node && node.collectionIds) { + for (const cid of node.collectionIds as CollectionId[]) { + const col = nextCollections[cid] + if (col) { + nextCollections[cid] = { ...col, nodeIds: col.nodeIds.filter((nid) => nid !== id) } + } + } + } + + // 4. Delete the node itself + delete nextNodes[id] + } + + return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections } + }) + + // Deleted ids must leave the dirty set: every consumer skips missing + // nodes without clearing them, so a mark on a deleted node would sit in + // the set (and defeat the consumers' empty-set early exit) forever. + for (const id of deletedIds) get().clearDirty(id) + + // Mark affected nodes dirty: parents of deleted nodes and their remaining children + // (e.g. deleting a slab affects sibling walls via level elevation changes) + parentsToMarkDirty.forEach((parentId) => { + get().markDirty(parentId) + const parent = get().nodes[parentId] + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) { + get().markDirty(childId as AnyNodeId) + } + } + }) + nodesToMarkDirty.forEach((id) => { + get().markDirty(id) + }) +} diff --git a/services/pascal-editor/packages/core/src/store/actions/node-mutation-sanitize.test.ts b/services/pascal-editor/packages/core/src/store/actions/node-mutation-sanitize.test.ts new file mode 100644 index 000000000..1863b5385 --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/actions/node-mutation-sanitize.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '../../schema/types' +import useScene from '../use-scene' + +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +const SHELF_ID = 'shelf_sanitize' as AnyNodeId +const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId + +function makeShelf(overrides: Partial = {}): AnyNode { + return { + id: SHELF_ID, + type: 'shelf', + parentId: null, + object: 'node', + visible: true, + name: 'Shelf', + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + width: 1.2, + depth: 0.3, + thickness: 0.04, + height: 0.9, + style: 'wall-shelf', + rows: 1, + columns: 1, + withBack: false, + withSides: true, + withBottom: false, + bracketStyle: 'minimal', + ...overrides, + } as unknown as AnyNode +} + +function makeSolarPanel(): AnyNode { + return { + id: SOLAR_PANEL_ID, + type: 'solar-panel', + parentId: null, + object: 'node', + visible: true, + name: 'Panel', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + rows: 2, + columns: 3, + panelWidth: 1, + panelHeight: 1.65, + gapX: 0.02, + gapY: 0.02, + mountingType: 'flush', + tiltAngle: 15, + standoffHeight: 0.05, + frameThickness: 0.04, + frameDepth: 0.04, + } as unknown as AnyNode +} + +function shelf() { + return useScene.getState().nodes[SHELF_ID] as Extract +} + +describe('node mutation numeric sanitization', () => { + beforeEach(() => { + useScene.setState({ + nodes: { + [SHELF_ID]: makeShelf(), + [SOLAR_PANEL_ID]: makeSolarPanel(), + }, + rootNodeIds: [SHELF_ID, SOLAR_PANEL_ID], + dirtyNodes: new Set(), + collections: {}, + readOnly: false, + } as never) + useScene.temporal.getState().clear() + }) + + test('drops NaN numeric updates while preserving other fields in the patch', () => { + useScene.getState().updateNode(SHELF_ID, { + thickness: Number.NaN, + name: 'Renamed after NaN', + } as Partial) + + expect(shelf().thickness).toBe(0.04) + expect(Number.isFinite(shelf().thickness)).toBe(true) + expect(shelf().name).toBe('Renamed after NaN') + }) + + test('drops Infinity numeric updates while preserving later normal updates', () => { + useScene.getState().updateNode(SHELF_ID, { + width: Infinity, + name: 'Renamed after Infinity', + } as Partial) + + expect(shelf().width).toBe(1.2) + expect(Number.isFinite(shelf().width)).toBe(true) + expect(shelf().name).toBe('Renamed after Infinity') + + useScene.getState().updateNode(SHELF_ID, { + name: 'Clean rename', + } as Partial) + + expect(shelf().name).toBe('Clean rename') + }) + + test('clamps out-of-range numeric updates to the node schema bounds', () => { + useScene.getState().updateNode(SHELF_ID, { + width: 99, + thickness: -1, + } as Partial) + + expect(shelf().width).toBe(3) + expect(shelf().thickness).toBe(0.01) + }) + + test('preserves extra fields while sanitizing numeric updates', () => { + useScene.setState({ + nodes: { + [SHELF_ID]: { + ...makeShelf(), + legacyField: 'current', + } as unknown as AnyNode, + }, + rootNodeIds: [SHELF_ID], + } as never) + + useScene.getState().updateNode(SHELF_ID, { + width: Infinity, + legacyPatch: 'patch', + } as Partial) + + const node = useScene.getState().nodes[SHELF_ID] as Record + expect(node.width).toBe(1.2) + expect(node.legacyField).toBe('current') + expect(node.legacyPatch).toBe('patch') + }) + + test('allows non-canonical ids to receive updates', () => { + useScene.getState().updateNode(SOLAR_PANEL_ID, { + name: 'Updated panel', + } as Partial) + + const panel = useScene.getState().nodes[SOLAR_PANEL_ID] as { name?: string } + expect(panel.name).toBe('Updated panel') + }) + + test('sanitizes non-finite numeric values during create', () => { + const createdId = 'shelf_created' as AnyNodeId + + useScene.getState().createNode( + makeShelf({ + id: createdId, + width: Infinity, + thickness: Number.NaN, + } as Partial), + ) + + const created = useScene.getState().nodes[createdId] as Extract + expect(created.width).toBe(1.2) + expect(created.thickness).toBe(0.04) + expect(Number.isFinite(created.width)).toBe(true) + expect(Number.isFinite(created.thickness)).toBe(true) + }) +}) diff --git a/services/pascal-editor/packages/core/src/store/actions/reparent.test.ts b/services/pascal-editor/packages/core/src/store/actions/reparent.test.ts new file mode 100644 index 000000000..7ee09547c --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/actions/reparent.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '../../schema/types' +import useScene from '../use-scene' + +// bun:test has no DOM — node-actions schedules markDirty via requestAnimationFrame, +// so polyfill it as synchronous. +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +const ROOF_ID = 'roof_test' as AnyNodeId +const SEG_A_ID = 'rseg_a' as AnyNodeId +const SEG_B_ID = 'rseg_b' as AnyNodeId +const VENT_ID = 'bvent_v1' as AnyNodeId + +function makeRoof(): AnyNode { + return { + id: ROOF_ID, + type: 'roof', + parentId: null, + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + children: [SEG_A_ID, SEG_B_ID], + } as unknown as AnyNode +} + +function makeSegment(id: AnyNodeId, children: AnyNodeId[] = []): AnyNode { + return { + id, + type: 'roof-segment', + parentId: ROOF_ID, + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 40, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + children, + } as unknown as AnyNode +} + +function makeVent(parentId: AnyNodeId): AnyNode { + return { + id: VENT_ID, + type: 'box-vent', + parentId, + roofSegmentId: parentId as string, + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + width: 0.4, + depth: 0.4, + height: 0.15, + hoodOverhang: 0.04, + topTaper: 0.4, + capHeight: 0.07, + capGap: 0, + domeCurvature: 0.65, + baseInset: 0.06, + baseHeight: 0.04, + cornerBevel: 0.012, + style: 'cap', + materialPreset: 'preset-white', + } as unknown as AnyNode +} + +function childrenOf(id: AnyNodeId): AnyNodeId[] { + const seg = useScene.getState().nodes[id] as { children?: AnyNodeId[] } | undefined + return (seg?.children ?? []) as AnyNodeId[] +} + +function vent(): AnyNode { + return useScene.getState().nodes[VENT_ID] as AnyNode +} + +describe('node-actions reparent — repeated segment-hopping', () => { + beforeEach(() => { + useScene.setState({ + nodes: { + [ROOF_ID]: makeRoof(), + [SEG_A_ID]: makeSegment(SEG_A_ID, [VENT_ID]), + [SEG_B_ID]: makeSegment(SEG_B_ID, []), + [VENT_ID]: makeVent(SEG_A_ID), + }, + rootNodeIds: [ROOF_ID], + } as never) + useScene.temporal.getState().clear() + }) + + test('initial state: vent listed once in A.children, absent from B.children', () => { + expect(childrenOf(SEG_A_ID)).toEqual([VENT_ID]) + expect(childrenOf(SEG_B_ID)).toEqual([]) + expect((vent() as { parentId: AnyNodeId }).parentId).toBe(SEG_A_ID) + }) + + test('A→B: auto-reparent moves vent without leaving duplicates', () => { + useScene + .getState() + .updateNode(VENT_ID, { parentId: SEG_B_ID, roofSegmentId: SEG_B_ID } as Partial) + + expect(childrenOf(SEG_A_ID)).toEqual([]) + expect(childrenOf(SEG_B_ID)).toEqual([VENT_ID]) + expect((vent() as { parentId: AnyNodeId }).parentId).toBe(SEG_B_ID) + }) + + test('A→B→A→B: three hops leave children lists clean (regression: vent crash)', () => { + // Move 1: A → B + useScene + .getState() + .updateNode(VENT_ID, { parentId: SEG_B_ID, roofSegmentId: SEG_B_ID } as Partial) + expect(childrenOf(SEG_A_ID)).toEqual([]) + expect(childrenOf(SEG_B_ID)).toEqual([VENT_ID]) + + // Move 2: B → A + useScene + .getState() + .updateNode(VENT_ID, { parentId: SEG_A_ID, roofSegmentId: SEG_A_ID } as Partial) + expect(childrenOf(SEG_A_ID)).toEqual([VENT_ID]) + expect(childrenOf(SEG_B_ID)).toEqual([]) + + // Move 3: A → B (the move the user reports crashes) + useScene + .getState() + .updateNode(VENT_ID, { parentId: SEG_B_ID, roofSegmentId: SEG_B_ID } as Partial) + expect(childrenOf(SEG_A_ID)).toEqual([]) + expect(childrenOf(SEG_B_ID)).toEqual([VENT_ID]) + expect((vent() as { parentId: AnyNodeId }).parentId).toBe(SEG_B_ID) + + // Crucially: the vent must appear in EXACTLY ONE segment's children. + // Duplicate listing is what makes the roof renderer mount two + // instances and crash the scene-registry + // on unmount. + const totalListings = + childrenOf(SEG_A_ID).filter((id) => id === VENT_ID).length + + childrenOf(SEG_B_ID).filter((id) => id === VENT_ID).length + expect(totalListings).toBe(1) + }) + + test.each([ + ['chimney', 'chmn_x'], + ['skylight', 'sky_x'], + ['dormer', 'dorm_x'], + ['solar-panel', 'sp_x'], + ['ridge-vent', 'rvent_x'], + ])('A→B→A→B for %s: chimney-style auto-reparent leaves children clean', (type, idStr) => { + const id = idStr as AnyNodeId + useScene.setState({ + nodes: { + [ROOF_ID]: makeRoof(), + [SEG_A_ID]: makeSegment(SEG_A_ID, [id]), + [SEG_B_ID]: makeSegment(SEG_B_ID, []), + [id]: { + id, + type, + parentId: SEG_A_ID, + roofSegmentId: SEG_A_ID, + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + } as unknown as AnyNode, + }, + rootNodeIds: [ROOF_ID], + } as never) + + const hop = (to: AnyNodeId) => + useScene.getState().updateNode(id, { parentId: to, roofSegmentId: to } as Partial) + + hop(SEG_B_ID) + hop(SEG_A_ID) + hop(SEG_B_ID) + + expect(childrenOf(SEG_A_ID)).toEqual([]) + expect(childrenOf(SEG_B_ID)).toEqual([id]) + const node = useScene.getState().nodes[id] as { parentId: AnyNodeId } + expect(node.parentId).toBe(SEG_B_ID) + }) + + test('A→B→A→B with redundant manual children edits (vent move-tool pattern, pre-fix)', () => { + // Reproduces the box-vent / ridge-vent move-tool order: manual + // children edits FIRST, then updateNode({parentId}) which triggers + // auto-reparent. The dual handling must not leave the vent listed + // in both segments after three hops. + const hop = (from: AnyNodeId, to: AnyNodeId) => { + const st = useScene.getState() + // 1. Manual: filter old children, push to new children — using + // the snapshot (st.nodes) captured before any updates. + const oldSeg = st.nodes[from] as { children?: AnyNodeId[] } | undefined + if (oldSeg) { + st.updateNode(from, { + children: (oldSeg.children ?? []).filter((id) => id !== VENT_ID), + } as Partial) + } + const newSeg = st.nodes[to] as { children?: AnyNodeId[] } | undefined + if (newSeg && !(newSeg.children ?? []).includes(VENT_ID)) { + st.updateNode(to, { + children: [...(newSeg.children ?? []), VENT_ID], + } as Partial) + } + // 2. Then the auto-reparent path via parentId. + st.updateNode(VENT_ID, { + parentId: to, + roofSegmentId: to, + } as Partial) + } + + hop(SEG_A_ID, SEG_B_ID) + hop(SEG_B_ID, SEG_A_ID) + hop(SEG_A_ID, SEG_B_ID) + + expect(childrenOf(SEG_A_ID)).toEqual([]) + expect(childrenOf(SEG_B_ID)).toEqual([VENT_ID]) + expect((vent() as { parentId: AnyNodeId }).parentId).toBe(SEG_B_ID) + + const totalListings = + childrenOf(SEG_A_ID).filter((id) => id === VENT_ID).length + + childrenOf(SEG_B_ID).filter((id) => id === VENT_ID).length + expect(totalListings).toBe(1) + }) +}) diff --git a/services/pascal-editor/packages/core/src/store/actions/ridge-vent-update.test.ts b/services/pascal-editor/packages/core/src/store/actions/ridge-vent-update.test.ts new file mode 100644 index 000000000..19bfac4b5 --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/actions/ridge-vent-update.test.ts @@ -0,0 +1,370 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { createDefaultRidgeVentsForSegment, RidgeVentNode } from '../../schema/nodes/ridge-vent' +import { RoofNode } from '../../schema/nodes/roof' +import { RoofSegmentNode } from '../../schema/nodes/roof-segment' +import type { AnyNode, AnyNodeId } from '../../schema/types' +import useScene from '../use-scene' + +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +describe('roof segment default ridge vents', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + readOnly: false, + }) + }) + + test('regenerates default ridge vents when the host ridge geometry changes', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 6, + }) + const defaults = createDefaultRidgeVentsForSegment(segment) + const custom = RidgeVentNode.parse({ + id: 'rvent_custom' as never, + parentId: segment.id, + roofSegmentId: segment.id, + name: 'Custom Ridge Vent', + position: [0, 0.2, 0], + length: 1.25, + materialPreset: 'preset-custom', + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: [...defaults.map((vent) => vent.id), custom.id], + } as AnyNode, + ...Object.fromEntries( + defaults.map((vent) => [ + vent.id, + { ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode, + ]), + ), + [custom.id]: custom as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + const oldDefaultIds = defaults.map((vent) => vent.id) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 12 } as Partial) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + const nextChildren = nextSegment?.children ?? [] + const nextDefaultIds = nextChildren.filter((id) => id !== custom.id) + + expect(nextChildren).toContain(custom.id) + expect(useScene.getState().nodes[custom.id as AnyNodeId]).toMatchObject({ + length: 1.25, + materialPreset: 'preset-custom', + }) + for (const oldId of oldDefaultIds) { + expect(useScene.getState().nodes[oldId as AnyNodeId]).toBeUndefined() + } + expect(nextDefaultIds).toHaveLength(defaults.length) + expect( + nextDefaultIds.some((id) => { + const node = useScene.getState().nodes[id as AnyNodeId] + return node?.type === 'ridge-vent' && node.length > (defaults[0]?.length ?? 0) + }), + ).toBe(true) + }) + + test('does not regenerate default ridge vents when only trim changes', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 6, + }) + const defaults = createDefaultRidgeVentsForSegment(segment) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: defaults.map((vent) => vent.id), + } as AnyNode, + ...Object.fromEntries( + defaults.map((vent) => [ + vent.id, + { ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode, + ]), + ), + } as Record, + [roof.id as AnyNodeId], + ) + + const originalDefaultId = defaults[0]?.id as AnyNodeId + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + trim: { ...segment.trim, left: 2, frontLeftX: 2, frontLeftZ: 3 }, + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + + expect(nextSegment?.children).toEqual(defaults.map((vent) => vent.id)) + expect(useScene.getState().nodes[originalDefaultId]).toMatchObject({ + id: originalDefaultId, + length: defaults[0]?.length, + position: defaults[0]?.position, + }) + }) + + test('creates default ridge vents after a geometry change when auto ridge vent is enabled', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'flat', + width: 8, + depth: 6, + metadata: { autoRidgeVent: true }, + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: segment as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + roofType: 'gable', + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + + expect(nextSegment?.children).toHaveLength(1) + expect(useScene.getState().nodes[nextSegment?.children[0] as AnyNodeId]).toMatchObject({ + type: 'ridge-vent', + roofSegmentId: segment.id, + }) + }) + + test('regenerates default ridge vents when Dutch auto-vent fields change', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'dutch', + width: 8, + depth: 6, + metadata: { autoRidgeVent: true }, + }) + const defaults = createDefaultRidgeVentsForSegment(segment) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: defaults.map((vent) => vent.id), + } as AnyNode, + ...Object.fromEntries( + defaults.map((vent) => [ + vent.id, + { ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode, + ]), + ), + } as Record, + [roof.id as AnyNodeId], + ) + + const originalDefaultIds = defaults.map((vent) => vent.id) + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + pitch: 52, + dutchWaistLengthRatio: 0.72, + dutchGabletRake: 0.9, + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + const nextChildren = nextSegment?.children ?? [] + + expect(nextChildren).toHaveLength(defaults.length) + expect( + nextChildren.some((id) => + originalDefaultIds.includes(id as (typeof originalDefaultIds)[number]), + ), + ).toBe(false) + for (const oldId of originalDefaultIds) { + expect(useScene.getState().nodes[oldId as AnyNodeId]).toBeUndefined() + } + }) + + test('refresh replaces legacy default vents that still use preset-white', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 6, + metadata: { autoRidgeVent: true }, + }) + const legacyDefault = RidgeVentNode.parse({ + id: 'rvent_legacy' as never, + parentId: segment.id, + roofSegmentId: segment.id, + name: 'Ridge Vent', + style: 'shingled', + materialPreset: 'preset-white', + position: [0, 0, 0], + length: 8, + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: [legacyDefault.id], + } as AnyNode, + [legacyDefault.id]: legacyDefault as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + pitch: 52, + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + const nextChildren = nextSegment?.children ?? [] + + expect(nextChildren).toHaveLength(1) + expect(nextChildren[0]).not.toBe(legacyDefault.id) + expect(useScene.getState().nodes[legacyDefault.id as AnyNodeId]).toBeUndefined() + }) + + test('refresh preserves user-created default-looking ridge vents without legacy preset metadata', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 6, + metadata: { autoRidgeVent: true }, + }) + const userVent = RidgeVentNode.parse({ + id: 'rvent_user' as never, + parentId: segment.id, + roofSegmentId: segment.id, + name: 'Ridge Vent', + style: 'shingled', + position: [0, 0, 0], + length: 8, + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: [userVent.id], + } as AnyNode, + [userVent.id]: userVent as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + pitch: 52, + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + const nextChildren = nextSegment?.children ?? [] + + expect(nextChildren).toContain(userVent.id) + expect(useScene.getState().nodes[userVent.id as AnyNodeId]).toMatchObject({ + id: userVent.id, + roofSegmentId: segment.id, + name: 'Ridge Vent', + style: 'shingled', + length: 8, + }) + }) + + test('does not create default ridge vents after a geometry change when auto ridge vent is disabled', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'flat', + width: 8, + depth: 6, + metadata: { autoRidgeVent: false }, + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: segment as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + roofType: 'gable', + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + + expect(nextSegment?.children ?? []).toHaveLength(0) + }) +}) diff --git a/services/pascal-editor/packages/core/src/store/history-control.ts b/services/pascal-editor/packages/core/src/store/history-control.ts new file mode 100644 index 000000000..51e191b60 --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/history-control.ts @@ -0,0 +1,36 @@ +let sceneHistoryPauseDepth = 0 + +type TemporalStoreLike = { + temporal: { + getState(): { + pause(): void + resume(): void + } + } +} + +export function pauseSceneHistory(sceneStore: TemporalStoreLike): void { + if (sceneHistoryPauseDepth === 0) { + sceneStore.temporal.getState().pause() + } + sceneHistoryPauseDepth += 1 +} + +export function resumeSceneHistory(sceneStore: TemporalStoreLike): void { + if (sceneHistoryPauseDepth === 0) { + return + } + + sceneHistoryPauseDepth -= 1 + if (sceneHistoryPauseDepth === 0) { + sceneStore.temporal.getState().resume() + } +} + +export function getSceneHistoryPauseDepth(): number { + return sceneHistoryPauseDepth +} + +export function resetSceneHistoryPauseDepth(): void { + sceneHistoryPauseDepth = 0 +} diff --git a/services/pascal-editor/packages/core/src/store/use-interactive.ts b/services/pascal-editor/packages/core/src/store/use-interactive.ts new file mode 100644 index 000000000..0a90a956b --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/use-interactive.ts @@ -0,0 +1,338 @@ +'use client' + +import { create } from 'zustand' +import type { Interactive } from '../schema/nodes/item' +import type { AnyNodeId } from '../schema/types' + +// Runtime value for each control (matches discriminated union kinds) +export type ControlValue = boolean | number + +export type ItemInteractiveState = { + // Indexed by control position in asset.interactive.controls[] + controlValues: ControlValue[] +} + +export type DoorInteractiveState = { + operationState?: number + swingAngle?: number +} + +export type DoorAnimationState = { + field: keyof DoorInteractiveState + from: number + to: number + startedAt: number | null + durationMs: number + persist: boolean +} + +export type WindowInteractiveState = { + operationState?: number +} + +export type WindowAnimationState = { + field: keyof WindowInteractiveState + from: number + to: number + startedAt: number | null + durationMs: number + persist: boolean +} + +export type SkylightInteractiveState = { + operationState?: number +} + +export type SkylightAnimationState = { + field: keyof SkylightInteractiveState + from: number + to: number + startedAt: number | null + durationMs: number + persist: boolean +} + +export type ElevatorPhase = 'idle' | 'closing' | 'moving' | 'opening' | 'open' + +export type ElevatorInteractiveState = { + currentLevelId: AnyNodeId | null + targetLevelId: AnyNodeId | null + carY: number + doorOpen: number + phase: ElevatorPhase + phaseStartedAt: number | null + queue: AnyNodeId[] + requestedStops: AnyNodeId[] +} + +type InteractiveStore = { + items: Record + doors: Record + doorAnimations: Record + windows: Record + windowAnimations: Record + skylights: Record + skylightAnimations: Record + elevators: Record + + /** Initialize a node's interactive state from its asset definition (idempotent) */ + initItem: (itemId: AnyNodeId, interactive: Interactive) => void + + /** Set a single control value */ + setControlValue: (itemId: AnyNodeId, index: number, value: ControlValue) => void + + /** Remove a node's state (e.g. on unmount) */ + removeItem: (itemId: AnyNodeId) => void + + /** Set transient door open state without committing it to the scene node */ + setDoorOpenState: (doorId: AnyNodeId, value: DoorInteractiveState) => void + + /** Clear transient door open state */ + removeDoorOpenState: (doorId: AnyNodeId) => void + + /** Queue a door animation for the viewer frame loop */ + startDoorAnimation: (doorId: AnyNodeId, value: DoorAnimationState) => void + + /** Cancel a queued door animation */ + cancelDoorAnimation: (doorId: AnyNodeId) => void + + /** Set transient window open state without committing it to the scene node */ + setWindowOpenState: (windowId: AnyNodeId, value: WindowInteractiveState) => void + + /** Clear transient window open state */ + removeWindowOpenState: (windowId: AnyNodeId) => void + + /** Queue a window animation for the viewer frame loop */ + startWindowAnimation: (windowId: AnyNodeId, value: WindowAnimationState) => void + + /** Cancel a queued window animation */ + cancelWindowAnimation: (windowId: AnyNodeId) => void + + /** Set transient skylight open state without committing it to the scene node */ + setSkylightOpenState: (skylightId: AnyNodeId, value: SkylightInteractiveState) => void + + /** Clear transient skylight open state */ + removeSkylightOpenState: (skylightId: AnyNodeId) => void + + /** Queue a skylight animation for the viewer frame loop */ + startSkylightAnimation: (skylightId: AnyNodeId, value: SkylightAnimationState) => void + + /** Cancel a queued skylight animation */ + cancelSkylightAnimation: (skylightId: AnyNodeId) => void + + /** Initialize an elevator's runtime state from its default served level. */ + initElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId, carY: number) => void + + /** Merge runtime elevator state. */ + setElevatorState: (elevatorId: AnyNodeId, value: Partial) => void + + /** Remove elevator runtime state when its renderer unmounts. */ + removeElevator: (elevatorId: AnyNodeId) => void +} + +const defaultControlValue = (interactive: Interactive, index: number): ControlValue => { + const control = interactive.controls[index] + if (!control) return false + switch (control.kind) { + case 'toggle': + return control.default ?? false + case 'slider': + return control.default ?? control.min + case 'temperature': + return control.default ?? control.min + } +} + +export const useInteractive = create((set, get) => ({ + items: {}, + doors: {}, + doorAnimations: {}, + windows: {}, + windowAnimations: {}, + skylights: {}, + skylightAnimations: {}, + elevators: {}, + + initItem: (itemId, interactive) => { + const { controls } = interactive + if (controls.length === 0) return + + // Don't overwrite existing state (idempotent) + if (get().items[itemId]) return + + set((state) => ({ + items: { + ...state.items, + [itemId]: { + controlValues: controls.map((_, i) => defaultControlValue(interactive, i)), + }, + }, + })) + }, + + setControlValue: (itemId, index, value) => { + set((state) => { + const item = state.items[itemId] + if (!item) return state + const next = [...item.controlValues] + next[index] = value + return { items: { ...state.items, [itemId]: { controlValues: next } } } + }) + }, + + removeItem: (itemId) => { + set((state) => { + const { [itemId]: _, ...rest } = state.items + return { items: rest } + }) + }, + + setDoorOpenState: (doorId, value) => { + set((state) => ({ + doors: { + ...state.doors, + [doorId]: { + ...state.doors[doorId], + ...value, + }, + }, + })) + }, + + removeDoorOpenState: (doorId) => { + set((state) => { + const { [doorId]: _, ...rest } = state.doors + return { doors: rest } + }) + }, + + startDoorAnimation: (doorId, value) => { + set((state) => ({ + doorAnimations: { + ...state.doorAnimations, + [doorId]: value, + }, + })) + }, + + cancelDoorAnimation: (doorId) => { + set((state) => { + const { [doorId]: _, ...rest } = state.doorAnimations + return { doorAnimations: rest } + }) + }, + + setWindowOpenState: (windowId, value) => { + set((state) => ({ + windows: { + ...state.windows, + [windowId]: { + ...state.windows[windowId], + ...value, + }, + }, + })) + }, + + removeWindowOpenState: (windowId) => { + set((state) => { + const { [windowId]: _, ...rest } = state.windows + return { windows: rest } + }) + }, + + startWindowAnimation: (windowId, value) => { + set((state) => ({ + windowAnimations: { + ...state.windowAnimations, + [windowId]: value, + }, + })) + }, + + cancelWindowAnimation: (windowId) => { + set((state) => { + const { [windowId]: _, ...rest } = state.windowAnimations + return { windowAnimations: rest } + }) + }, + + setSkylightOpenState: (skylightId, value) => { + set((state) => ({ + skylights: { + ...state.skylights, + [skylightId]: { + ...state.skylights[skylightId], + ...value, + }, + }, + })) + }, + + removeSkylightOpenState: (skylightId) => { + set((state) => { + const { [skylightId]: _, ...rest } = state.skylights + return { skylights: rest } + }) + }, + + startSkylightAnimation: (skylightId, value) => { + set((state) => ({ + skylightAnimations: { + ...state.skylightAnimations, + [skylightId]: value, + }, + })) + }, + + cancelSkylightAnimation: (skylightId) => { + set((state) => { + const { [skylightId]: _, ...rest } = state.skylightAnimations + return { skylightAnimations: rest } + }) + }, + + initElevator: (elevatorId, levelId, carY) => { + if (get().elevators[elevatorId]) return + + set((state) => ({ + elevators: { + ...state.elevators, + [elevatorId]: { + currentLevelId: levelId, + targetLevelId: null, + carY, + doorOpen: 0, + phase: 'idle', + phaseStartedAt: null, + queue: [], + requestedStops: [], + }, + }, + })) + }, + + setElevatorState: (elevatorId, value) => { + set((state) => { + const current = state.elevators[elevatorId] + if (!current) return state + + return { + elevators: { + ...state.elevators, + [elevatorId]: { + ...current, + ...value, + }, + }, + } + }) + }, + + removeElevator: (elevatorId) => { + set((state) => { + const { [elevatorId]: _, ...rest } = state.elevators + return { elevators: rest } + }) + }, +})) diff --git a/services/pascal-editor/packages/core/src/store/use-live-node-overrides.ts b/services/pascal-editor/packages/core/src/store/use-live-node-overrides.ts new file mode 100644 index 000000000..50e6c98b5 --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/use-live-node-overrides.ts @@ -0,0 +1,76 @@ +import { create } from 'zustand' + +export type LiveNodeOverrides = Record + +type LiveNodeOverrideState = { + overrides: Map + set(nodeId: string, values: LiveNodeOverrides): void + setMany(entries: ReadonlyArray): void + get(nodeId: string): LiveNodeOverrides | undefined + clear(nodeId: string): void + clearFields(nodeId: string, keys: readonly string[]): void + clearAll(): void +} + +const useLiveNodeOverrides = create((set, get) => ({ + overrides: new Map(), + set: (nodeId, values) => + set((state) => { + const next = new Map(state.overrides) + next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values }) + return { overrides: next } + }), + // Batch update — one Map clone + one zustand notification regardless + // of entry count, so a drag publishing to N linked walls re-renders + // subscribers (WallSystem, FloorplanRegistryLayer) once per tick + // instead of N+1 times. + setMany: (entries) => + set((state) => { + if (entries.length === 0) return state + const next = new Map(state.overrides) + for (const [nodeId, values] of entries) { + next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values }) + } + return { overrides: next } + }), + get: (nodeId) => get().overrides.get(nodeId), + clear: (nodeId) => + set((state) => { + const next = new Map(state.overrides) + next.delete(nodeId) + return { overrides: next } + }), + clearFields: (nodeId, keys) => + set((state) => { + const current = state.overrides.get(nodeId) + if (!current) return state + + const nextValues = { ...current } + for (const key of keys) { + delete nextValues[key] + } + + const next = new Map(state.overrides) + if (Object.keys(nextValues).length === 0) { + next.delete(nodeId) + } else { + next.set(nodeId, nextValues) + } + return { overrides: next } + }), + clearAll: () => set({ overrides: new Map() }), +})) + +/** + * Merge any live override for `node` into a fresh copy. Spread semantics — + * override fields win, untouched fields stay. Returns the input unchanged + * when no override exists, so the caller can use the result directly + * without an extra "did anything change" check. + */ +export function getEffectiveNode(node: T): T { + const override = useLiveNodeOverrides.getState().overrides.get(node.id) + if (!override || Object.keys(override).length === 0) return node + return { ...node, ...override } as T +} + +export default useLiveNodeOverrides diff --git a/services/pascal-editor/packages/core/src/store/use-live-transforms.ts b/services/pascal-editor/packages/core/src/store/use-live-transforms.ts new file mode 100644 index 000000000..b2aef7dd0 --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/use-live-transforms.ts @@ -0,0 +1,38 @@ +// Ephemeral live transform state for nodes being actively dragged/moved. +// This decouples 2D (floorplan) and 3D (viewer) so neither needs to peek +// into the other's scene graph during drag operations. + +import { create } from 'zustand' + +export type LiveTransform = { + position: [number, number, number] + rotation: number // Y-axis rotation (plan-view rotation) +} + +type LiveTransformState = { + transforms: Map + set(nodeId: string, transform: LiveTransform): void + get(nodeId: string): LiveTransform | undefined + clear(nodeId: string): void + clearAll(): void +} + +const useLiveTransforms = create((set, get) => ({ + transforms: new Map(), + set: (nodeId, transform) => + set((state) => { + const next = new Map(state.transforms) + next.set(nodeId, transform) + return { transforms: next } + }), + get: (nodeId) => get().transforms.get(nodeId), + clear: (nodeId) => + set((state) => { + const next = new Map(state.transforms) + next.delete(nodeId) + return { transforms: next } + }), + clearAll: () => set({ transforms: new Map() }), +})) + +export default useLiveTransforms diff --git a/services/pascal-editor/packages/core/src/store/use-scene-dirty-tracking.test.ts b/services/pascal-editor/packages/core/src/store/use-scene-dirty-tracking.test.ts new file mode 100644 index 000000000..5a8635c81 --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/use-scene-dirty-tracking.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { nodeRegistry } from '../registry/registry' +import type { AnyNodeDefinition } from '../registry/types' +import type { AnyNode, AnyNodeId } from '../schema/types' +import useScene from './use-scene' + +const untrackedDef = { + kind: 'test-untracked', + schemaVersion: 1, + schema: {} as never, + category: 'furnishing', + defaults: () => ({}), + capabilities: {}, + dirtyTracking: false, +} as unknown as AnyNodeDefinition + +const trackedDef = { + ...untrackedDef, + kind: 'test-tracked', + dirtyTracking: undefined, +} as unknown as AnyNodeDefinition + +const UNTRACKED = 'item_untracked' as AnyNodeId +const TRACKED = 'item_tracked' as AnyNodeId +const UNREGISTERED = 'item_unregistered' as AnyNodeId + +const makeNode = (id: AnyNodeId, type: string): AnyNode => + ({ + object: 'node', + id, + type, + parentId: null, + visible: true, + metadata: {}, + children: [], + }) as unknown as AnyNode + +describe('dirty tracking', () => { + beforeEach(() => { + if (!nodeRegistry.has(untrackedDef.kind)) nodeRegistry._register(untrackedDef) + if (!nodeRegistry.has(trackedDef.kind)) nodeRegistry._register(trackedDef) + useScene.setState({ + nodes: { + [UNTRACKED]: makeNode(UNTRACKED, 'test-untracked'), + [TRACKED]: makeNode(TRACKED, 'test-tracked'), + [UNREGISTERED]: makeNode(UNREGISTERED, 'unregistered-kind'), + }, + rootNodeIds: [UNTRACKED, TRACKED, UNREGISTERED], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + // Membership asserts (not set size/equality): the scene store is a module + // singleton, and subscribers leaked by other test files can add their own + // dirty marks when `setState` fires. + test('markDirty skips kinds whose definition opts out', () => { + useScene.getState().markDirty(UNTRACKED) + expect(useScene.getState().dirtyNodes.has(UNTRACKED)).toBe(false) + }) + + test('markDirty tracks kinds without the opt-out, registered or not', () => { + useScene.getState().markDirty(TRACKED) + useScene.getState().markDirty(UNREGISTERED) + expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(true) + expect(useScene.getState().dirtyNodes.has(UNREGISTERED)).toBe(true) + }) + + test('deleteNodes removes deleted ids from the dirty set', () => { + useScene.getState().markDirty(TRACKED) + expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(true) + useScene.getState().deleteNodes([TRACKED]) + expect(useScene.getState().nodes[TRACKED]).toBeUndefined() + expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(false) + }) +}) diff --git a/services/pascal-editor/packages/core/src/store/use-scene-elevator-migration.test.ts b/services/pascal-editor/packages/core/src/store/use-scene-elevator-migration.test.ts new file mode 100644 index 000000000..050180a0b --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/use-scene-elevator-migration.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +describe('scene elevator migrations', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('normalizes legacy level-parented elevators into building-scoped nodes', () => { + useScene.getState().setScene( + { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + }, + building_test: { + object: 'node', + id: 'building_test', + type: 'building', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'building_test', + visible: true, + metadata: {}, + children: ['elevator_test'], + level: 0, + }, + elevator_test: { + object: 'node', + id: 'elevator_test', + type: 'elevator', + parentId: 'level_test', + visible: true, + metadata: {}, + }, + } as unknown as Record, + ['site_test'] as never, + ) + + const nodes = useScene.getState().nodes + const elevator = nodes.elevator_test as Extract + const level = nodes.level_test as Extract + const building = nodes.building_test as Extract + + expect(elevator.parentId).toBe('building_test') + expect(elevator.position).toEqual([0, 0, 0]) + expect(elevator.rotation).toBe(0) + expect(level.children).not.toContain('elevator_test') + expect(building.children).toContain('elevator_test') + }) + + test('migrates level-parented elevators when the level parentId is missing', () => { + useScene.getState().setScene( + { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + }, + building_test: { + object: 'node', + id: 'building_test', + type: 'building', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: ['elevator_test'], + level: 0, + }, + elevator_test: { + object: 'node', + id: 'elevator_test', + type: 'elevator', + parentId: 'level_test', + visible: true, + metadata: {}, + }, + } as unknown as Record, + ['site_test'] as never, + ) + + const nodes = useScene.getState().nodes + const elevator = nodes.elevator_test as Extract + const level = nodes.level_test as Extract + const building = nodes.building_test as Extract + + expect(elevator.parentId).toBe('building_test') + expect(level.children).not.toContain('elevator_test') + expect(building.children).toContain('elevator_test') + }) +}) diff --git a/services/pascal-editor/packages/core/src/store/use-scene-wall-slot-migration.test.ts b/services/pascal-editor/packages/core/src/store/use-scene-wall-slot-migration.test.ts new file mode 100644 index 000000000..ef1e90af8 --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/use-scene-wall-slot-migration.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +type WallNode = Extract + +function sceneWithWall(wall: Record): Record { + return { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['wall_test'], + level: 0, + }, + wall_test: { + object: 'node', + id: 'wall_test', + type: 'wall', + parentId: 'level_test', + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + ...wall, + }, + } as unknown as Record +} + +function sceneWithNode(node: Record): Record { + return { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['node_test'], + level: 0, + }, + node_test: { + object: 'node', + id: 'node_test', + visible: true, + metadata: {}, + parentId: 'level_test', + ...node, + }, + } as unknown as Record +} + +function resetScene() { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + } as never) + useScene.temporal.getState().clear() +} + +describe('wall surface-material → slots migration', () => { + beforeEach(() => { + resetScene() + }) + + test('moves legacy library presets into slots and clears the inline fields', () => { + useScene.getState().setScene( + sceneWithWall({ + interiorMaterialPreset: 'library:concrete-plate', + exteriorMaterialPreset: 'library:wood-woodplank48', + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + expect(wall.slots).toEqual({ + interior: 'library:concrete-plate', + exterior: 'library:wood-woodplank48', + }) + expect(wall.interiorMaterialPreset).toBeUndefined() + expect(wall.exteriorMaterialPreset).toBeUndefined() + }) + + test('mints a scene material for an inline legacy material and references it', () => { + useScene.getState().setScene( + sceneWithWall({ + interiorMaterial: { properties: { color: '#abcdef' } }, + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + const interiorRef = wall.slots?.interior + expect(interiorRef?.startsWith('scene:')).toBe(true) + expect(wall.interiorMaterial).toBeUndefined() + + const materials = useScene.getState().materials + const id = interiorRef!.slice('scene:'.length) + expect(materials[id as keyof typeof materials]?.material).toEqual({ + properties: { color: '#abcdef' }, + } as never) + }) + + test('legacy catch-all material applies to both faces; identical inline customs share one scene material', () => { + useScene.getState().setScene( + sceneWithWall({ + material: { properties: { color: '#112233' } }, + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + expect(wall.slots?.interior).toBeDefined() + expect(wall.slots?.interior).toBe(wall.slots?.exterior as string) + expect(wall.material).toBeUndefined() + // One minted datablock shared across both faces. + expect(Object.keys(useScene.getState().materials)).toHaveLength(1) + }) + + test('leaves an already slot-modelled wall untouched and mints nothing for unpainted walls', () => { + useScene + .getState() + .setScene(sceneWithWall({ slots: { interior: 'library:concrete-drywall' } }), [ + 'site_test', + ] as never) + + const migratedWall = useScene.getState().nodes.wall_test as WallNode + expect(migratedWall.slots).toEqual({ interior: 'library:concrete-drywall' }) + expect(Object.keys(useScene.getState().materials)).toHaveLength(0) + + useScene.getState().setScene(sceneWithWall({}), ['site_test'] as never) + const plainWall = useScene.getState().nodes.wall_test as WallNode + expect(plainWall.slots).toBeUndefined() + expect(Object.keys(useScene.getState().materials)).toHaveLength(0) + }) +}) + +type SlottedNode = AnyNode & { slots?: Record; material?: unknown } + +describe('procedural kind surface-material → slots migration', () => { + beforeEach(resetScene) + + test('slab: legacy preset → slots.surface, legacy cleared', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + materialPreset: 'library:flooring-tiles3', + }), + ['site_test'] as never, + ) + + const slab = (useScene.getState().nodes as Record).node_test! + expect(slab.slots).toEqual({ surface: 'library:flooring-tiles3' }) + expect((slab as { materialPreset?: unknown }).materialPreset).toBeUndefined() + }) + + test('ceiling: inline custom material mints a scene material on slots.surface', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'ceiling', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + ], + material: { properties: { color: '#ddeeff' } }, + }), + ['site_test'] as never, + ) + + const ceiling = (useScene.getState().nodes as Record).node_test! + const ref = ceiling.slots?.surface + expect(ref?.startsWith('scene:')).toBe(true) + expect(ceiling.material).toBeUndefined() + expect(Object.keys(useScene.getState().materials)).toHaveLength(1) + }) + + test('fence: legacy preset fans out to every slot id (one shared ref)', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'fence', + start: [0, 0], + end: [4, 0], + materialPreset: 'library:wood-woodplank48', + }), + ['site_test'] as never, + ) + + const fence = (useScene.getState().nodes as Record).node_test! + expect(fence.slots).toEqual({ + posts: 'library:wood-woodplank48', + infill: 'library:wood-woodplank48', + base: 'library:wood-woodplank48', + rail: 'library:wood-woodplank48', + }) + }) + + test('stair: per-role legacy fields map tread→treads, side→body, railing→railing', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'stair', + treadMaterialPreset: 'library:wood-woodplank48', + sideMaterialPreset: 'library:concrete-plate', + railingMaterialPreset: 'library:metal-chrome', + }), + ['site_test'] as never, + ) + + const stair = (useScene.getState().nodes as Record).node_test! + expect(stair.slots?.treads).toBe('library:wood-woodplank48') + expect(stair.slots?.body).toBe('library:concrete-plate') + expect(stair.slots?.railing).toBe('library:metal-chrome') + expect((stair as { treadMaterialPreset?: unknown }).treadMaterialPreset).toBeUndefined() + }) + + test('unpainted procedural node mints nothing and stays slot-less', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + ], + }), + ['site_test'] as never, + ) + + const slab = (useScene.getState().nodes as Record).node_test! + expect(slab.slots).toBeUndefined() + expect(Object.keys(useScene.getState().materials)).toHaveLength(0) + }) +}) diff --git a/services/pascal-editor/packages/core/src/store/use-scene-window-migration.test.ts b/services/pascal-editor/packages/core/src/store/use-scene-window-migration.test.ts new file mode 100644 index 000000000..6911d86b5 --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/use-scene-window-migration.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +describe('scene window migrations', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('fills schema defaults on windows saved before a field existed', () => { + // Mirrors real legacy scenes (e.g. windows persisted without + // columnRatios/rowRatios/frameThickness): the mesh builder reads those + // unconditionally, so a missing array crashed the viewer every frame. + useScene.getState().setScene( + { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + }, + building_test: { + object: 'node', + id: 'building_test', + type: 'building', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'building_test', + visible: true, + metadata: {}, + children: ['wall_test'], + level: 0, + }, + wall_test: { + object: 'node', + id: 'wall_test', + type: 'wall', + parentId: 'level_test', + visible: true, + metadata: {}, + children: ['window_test'], + start: [0, 0], + end: [4, 0], + height: 2.5, + thickness: 0.2, + }, + window_test: { + object: 'node', + id: 'window_test', + type: 'window', + parentId: 'wall_test', + visible: true, + metadata: {}, + wallId: 'wall_test', + position: [1, 1, 0], + width: 1.2, + height: 1.5, + windowType: 'fixed', + }, + } as unknown as Record, + ['site_test'] as never, + ) + + const window = useScene.getState().nodes.window_test as Extract + expect(window).toBeDefined() + // Schema defaults land on load… + expect(window.columnRatios).toEqual([1]) + expect(window.rowRatios).toEqual([1]) + expect(window.frameThickness).toBe(0.05) + expect(window.sill).toBe(true) + // …and authored fields survive. + expect(window.width).toBe(1.2) + expect(window.height).toBe(1.5) + expect(window.wallId).toBe('wall_test') + }) +}) diff --git a/services/pascal-editor/packages/core/src/store/use-scene.ts b/services/pascal-editor/packages/core/src/store/use-scene.ts new file mode 100644 index 000000000..643065edc --- /dev/null +++ b/services/pascal-editor/packages/core/src/store/use-scene.ts @@ -0,0 +1,1337 @@ +'use client' + +import type { TemporalState } from 'zundo' +import { temporal } from 'zundo' +import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { parseMaterialRef, toSceneMaterialRef } from '../material-library' +import { nodeRegistry } from '../registry/registry' +import { BuildingNode } from '../schema' +import type { Collection, CollectionId } from '../schema/collections' +import { generateCollectionId } from '../schema/collections' +import { DoorNode as DoorNodeSchema } from '../schema/nodes/door' +import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator' +import { LevelNode } from '../schema/nodes/level' +import { + getPitchFromActiveRoofHeight, + type RoofSegmentNode, + type RoofType, +} from '../schema/nodes/roof-segment' +import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls' +import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf' +import { SiteNode } from '../schema/nodes/site' +import { + getEffectiveStairSurfaceMaterial, + StairNode as StairNodeSchema, +} from '../schema/nodes/stair' +import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' +import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall' +import { WindowNode as WindowNodeSchema } from '../schema/nodes/window' +import { + generateSceneMaterialId, + type SceneMaterial, + type SceneMaterialId, +} from '../schema/scene-material' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { healSceneNodes } from '../utils/heal-scene-graph' +import * as nodeActions from './actions/node-actions' +import { resetSceneHistoryPauseDepth } from './history-control' + +function getFiniteNumber(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function getFiniteNumberInRange(value: unknown, fallback: number, min: number, max: number) { + const finite = getFiniteNumber(value, fallback) + return Math.min(Math.max(finite, min), max) +} + +function getBoolean(value: unknown, fallback: boolean) { + return typeof value === 'boolean' ? value : fallback +} + +function getEnumValue( + value: unknown, + allowed: T, + fallback: T[number], +): T[number] { + return typeof value === 'string' && allowed.includes(value) ? value : fallback +} + +function getNullableString(value: unknown) { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function getStringArray(value: unknown) { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === 'string') + : [] +} + +function getVector3(value: unknown, fallback: [number, number, number]): [number, number, number] { + if (!Array.isArray(value) || value.length < 3) { + return fallback + } + + return [ + getFiniteNumber(value[0], fallback[0]), + getFiniteNumber(value[1], fallback[1]), + getFiniteNumber(value[2], fallback[2]), + ] +} + +function normalizeStairNode(node: Record) { + const sanitized = { + ...node, + position: getVector3(node.position, [0, 0, 0]), + rotation: getFiniteNumber(node.rotation, 0), + stairType: getEnumValue(node.stairType, ['straight', 'curved', 'spiral'] as const, 'straight'), + fromLevelId: getNullableString(node.fromLevelId), + toLevelId: getNullableString(node.toLevelId), + slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'), + openingOffset: getFiniteNumber(node.openingOffset, 0), + width: getFiniteNumber(node.width, 1), + totalRise: getFiniteNumber(node.totalRise, 2.5), + stepCount: getFiniteNumber(node.stepCount, 10), + thickness: getFiniteNumber(node.thickness, 0.25), + fillToFloor: getBoolean(node.fillToFloor, true), + innerRadius: getFiniteNumber(node.innerRadius, 0.9), + sweepAngle: getFiniteNumber(node.sweepAngle, Math.PI / 2), + topLandingMode: getEnumValue(node.topLandingMode, ['none', 'integrated'] as const, 'none'), + topLandingDepth: getFiniteNumber(node.topLandingDepth, 0.9), + showCenterColumn: getBoolean(node.showCenterColumn, true), + showStepSupports: getBoolean(node.showStepSupports, true), + railingMode: getEnumValue(node.railingMode, ['none', 'left', 'right', 'both'] as const, 'none'), + railingHeight: getFiniteNumber(node.railingHeight, 0.92), + children: getStringArray(node.children), + } + + const parsed = StairNodeSchema.safeParse(sanitized) + return parsed.success ? parsed.data : null +} + +function normalizeStairSegmentNode(node: Record) { + const sanitized = { + ...node, + position: getVector3(node.position, [0, 0, 0]), + rotation: getFiniteNumber(node.rotation, 0), + segmentType: getEnumValue(node.segmentType, ['stair', 'landing'] as const, 'stair'), + width: getFiniteNumber(node.width, 1), + length: getFiniteNumber(node.length, 3), + height: getFiniteNumber(node.height, 2.5), + stepCount: getFiniteNumber(node.stepCount, 10), + attachmentSide: getEnumValue(node.attachmentSide, ['front', 'left', 'right'] as const, 'front'), + fillToFloor: getBoolean(node.fillToFloor, true), + thickness: getFiniteNumber(node.thickness, 0.25), + } + + const parsed = StairSegmentNodeSchema.safeParse(sanitized) + return parsed.success ? parsed.data : null +} + +function normalizeDoorNode(node: Record) { + const parsed = DoorNodeSchema.safeParse(node) + return parsed.success ? { ...node, ...parsed.data } : null +} + +// Windows saved before a schema field existed (e.g. `columnRatios`/`rowRatios`/ +// `frameThickness`) load without it; the mesh builder then reads undefined and +// throws every frame. Zod-parse on load so schema defaults land, like doors. +function normalizeWindowNode(node: Record) { + const parsed = WindowNodeSchema.safeParse(node) + return parsed.success ? { ...node, ...parsed.data } : null +} + +function normalizeShelfNode(node: Record) { + const sanitized = { + ...node, + children: getStringArray(node.children), + position: getVector3(node.position, [0, 0, 0]), + rotation: getVector3(node.rotation, [0, 0, 0]), + width: getFiniteNumberInRange(node.width, 1.2, 0.3, 3.0), + depth: getFiniteNumberInRange(node.depth, 0.3, 0.1, 1.0), + thickness: getFiniteNumberInRange(node.thickness, 0.04, 0.01, 0.1), + height: getFiniteNumberInRange(node.height, 0.9, 0.05, 2.5), + rows: Math.round(getFiniteNumberInRange(node.rows, 1, 1, 8)), + columns: Math.round(getFiniteNumberInRange(node.columns, 1, 1, 6)), + style: getEnumValue( + node.style, + ['wall-shelf', 'bookshelf', 'open-rack', 'cubby'] as const, + 'wall-shelf', + ), + withBack: getBoolean(node.withBack, false), + withSides: getBoolean(node.withSides, true), + withBottom: getBoolean(node.withBottom, false), + bracketStyle: getEnumValue( + node.bracketStyle, + ['minimal', 'industrial', 'hidden'] as const, + 'minimal', + ), + } + + const parsed = ShelfNodeSchema.safeParse(sanitized) + return parsed.success ? parsed.data : null +} + +function normalizeElevatorNode(node: Record) { + const sanitized = { + ...node, + position: getVector3(node.position, [0, 0, 0]), + rotation: getFiniteNumber(node.rotation, 0), + width: getFiniteNumber(node.width, 1.84), + depth: getFiniteNumber(node.depth, 1.84), + shaftWidth: node.shaftWidth === undefined ? undefined : getFiniteNumber(node.shaftWidth, 1.84), + shaftDepth: node.shaftDepth === undefined ? undefined : getFiniteNumber(node.shaftDepth, 1.84), + shaftWallThickness: getFiniteNumber(node.shaftWallThickness, 0.09), + cabHeight: getFiniteNumber(node.cabHeight, 2.35), + doorWidth: getFiniteNumber(node.doorWidth, 0.95), + doorHeight: getFiniteNumber(node.doorHeight, 2.1), + fromLevelId: getNullableString(node.fromLevelId), + toLevelId: getNullableString(node.toLevelId), + servedLevelIds: + node.servedLevelIds === undefined ? undefined : getStringArray(node.servedLevelIds), + disabledLevelIds: getStringArray(node.disabledLevelIds), + serviceOnlyLevelIds: getStringArray(node.serviceOnlyLevelIds), + defaultLevelId: getNullableString(node.defaultLevelId), + speed: getFiniteNumber(node.speed, 2.2), + doorDurationMs: getFiniteNumber(node.doorDurationMs, 900), + dwellMs: getFiniteNumber(node.dwellMs, 1400), + } + + const parsed = ElevatorNodeSchema.safeParse(sanitized) + return parsed.success ? parsed.data : null +} + +function findBuildingIdForLevel(levelId: string, nodes: Record): string | null { + const level = nodes[levelId] + const directBuildingId = typeof level?.parentId === 'string' ? level.parentId : null + if (directBuildingId && nodes[directBuildingId]?.type === 'building') { + return directBuildingId + } + + for (const [candidateId, candidate] of Object.entries(nodes)) { + if (candidate?.type !== 'building') continue + if (getStringArray(candidate.children).includes(levelId)) { + return candidateId + } + } + + return null +} + +function migrateElevatorParent( + id: string, + node: Record, + nodes: Record, +) { + const parentId = typeof node.parentId === 'string' ? node.parentId : null + if (!parentId) return node + const parent = parentId ? nodes[parentId] : null + if (parent?.type !== 'level') return node + + const buildingId = findBuildingIdForLevel(parentId, nodes) + if (!buildingId) return node + const building = buildingId ? nodes[buildingId] : null + if (building?.type !== 'building') return node + + nodes[parentId] = { + ...parent, + children: getStringArray(parent.children).filter((childId) => childId !== id), + } + + const buildingChildren = getStringArray(building.children) + nodes[buildingId] = { + ...building, + children: buildingChildren.includes(id) ? buildingChildren : [...buildingChildren, id], + } + + return { + ...node, + parentId: buildingId, + } +} + +// Reuse an already-minted scene material for an identical inline legacy +// material so a whole building painted one custom colour collapses to one +// shared datablock (mirrors `commitSlotPaint`'s dedupe-on-match). +function findMintedSceneMaterialRef( + material: unknown, + mintedMaterials: Record, +): string | undefined { + const target = JSON.stringify(material) + for (const sceneMaterial of Object.values(mintedMaterials)) { + if (JSON.stringify(sceneMaterial.material) === target) { + return toSceneMaterialRef(sceneMaterial.id) + } + } + return undefined +} + +// Turn a legacy surface spec (`{ material, materialPreset }`) into a +// `MaterialRef`: a preset that's already a `library:`/`scene:` ref is used +// as-is; an inline material mints (or reuses) a scene material. Returns +// undefined when the spec carries no material. Shared by every legacy→slots +// migration below. +function legacySpecToMaterialRef( + spec: { material?: unknown; materialPreset?: unknown }, + mintedMaterials: Record, +): string | undefined { + if (typeof spec.materialPreset === 'string' && parseMaterialRef(spec.materialPreset)) { + return spec.materialPreset + } + if (spec.material !== undefined) { + const existing = findMintedSceneMaterialRef(spec.material, mintedMaterials) + if (existing) return existing + const id = generateSceneMaterialId() + mintedMaterials[id] = { + id, + name: `Material ${Object.keys(mintedMaterials).length + 1}`, + material: spec.material as SceneMaterial['material'], + } + return toSceneMaterialRef(id) + } + return undefined +} + +// Move the retired inline `material*` / `interiorMaterial*` / `exteriorMaterial*` +// fields onto the unified `node.slots` model (interior / exterior → a +// `library:`/`scene:` ref), minting scene materials for inline customs into +// `mintedMaterials` (merged into the scene material map by the caller). Already +// slot-modelled walls and walls with no legacy material are left untouched. +function migrateWallSurfaceMaterials( + node: Record, + mintedMaterials: Record, +) { + if (node.slots && (node.slots.interior !== undefined || node.slots.exterior !== undefined)) { + return node + } + + const slots: Record = { ...(node.slots ?? {}) } + for (const side of ['interior', 'exterior'] as WallSurfaceSide[]) { + const spec = getEffectiveWallSurfaceMaterial( + node as Parameters[0], + side, + ) + const ref = legacySpecToMaterialRef(spec, mintedMaterials) + if (ref) slots[side] = ref + } + + if (Object.keys(slots).length === 0) { + return node + } + + return { + ...node, + slots, + material: undefined, + materialPreset: undefined, + interiorMaterial: undefined, + interiorMaterialPreset: undefined, + exteriorMaterial: undefined, + exteriorMaterialPreset: undefined, + } +} + +// Move a kind's single legacy `material` / `materialPreset` onto its declared +// slots. A pre-slot-model node painted one material rendered that material on +// every part (each slot resolves `node.slots[slot]` → legacy → default), so the +// migration writes the same ref to every slot id the kind can expose — unused +// conditional slots are harmless. Already slot-modelled or unpainted nodes are +// left untouched. Mirrors `migrateWallSurfaceMaterials` for single-surface and +// whole-object kinds (slab, ceiling, fence, column, shelf). +function migrateSingleMaterialSlots( + node: Record, + slotIds: readonly string[], + mintedMaterials: Record, +) { + if (node.slots && Object.keys(node.slots).length > 0) { + return node + } + + const ref = legacySpecToMaterialRef( + { material: node.material, materialPreset: node.materialPreset }, + mintedMaterials, + ) + if (!ref) { + return node + } + + const slots: Record = {} + for (const slotId of slotIds) slots[slotId] = ref + + return { ...node, slots, material: undefined, materialPreset: undefined } +} + +// Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` / +// `railingMaterial*`) plus a catch-all. Map each to its slot via the same +// fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`): +// tread→treads, side→body, railing→railing. Runs after +// `migrateStairSurfaceMaterials` has normalised the legacy fields. +function migrateStairSurfaceSlots( + node: Record, + mintedMaterials: Record, +) { + if (node.slots && Object.keys(node.slots).length > 0) { + return node + } + + const roleToSlot = [ + ['tread', 'treads'], + ['side', 'body'], + ['railing', 'railing'], + ] as const + + const slots: Record = {} + for (const [role, slotId] of roleToSlot) { + const spec = getEffectiveStairSurfaceMaterial( + node as Parameters[0], + role, + ) + const ref = legacySpecToMaterialRef(spec, mintedMaterials) + if (ref) slots[slotId] = ref + } + + if (Object.keys(slots).length === 0) { + return node + } + + return { + ...node, + slots, + material: undefined, + materialPreset: undefined, + treadMaterial: undefined, + treadMaterialPreset: undefined, + sideMaterial: undefined, + sideMaterialPreset: undefined, + railingMaterial: undefined, + railingMaterialPreset: undefined, + } +} + +function migrateStairSurfaceMaterials(node: Record) { + const hasRailing = + node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string' + const hasTread = node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string' + const hasSide = node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string' + const legacyFinish = { + material: node.material, + materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, + } + + const resolveBodyFallback = () => { + if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') { + return { + material: node.treadMaterial, + materialPreset: + typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + } + } + + if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') { + return { + material: node.sideMaterial, + materialPreset: + typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + } + } + + return legacyFinish + } + + if (!(hasRailing || hasTread || hasSide)) { + if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { + return node + } + + return { + ...node, + railingMaterial: legacyFinish.material, + railingMaterialPreset: legacyFinish.materialPreset, + treadMaterial: legacyFinish.material, + treadMaterialPreset: legacyFinish.materialPreset, + sideMaterial: legacyFinish.material, + sideMaterialPreset: legacyFinish.materialPreset, + } + } + + const next = { ...node } + + if (!hasTread) { + const fallback = + node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string' + ? { + material: node.sideMaterial, + materialPreset: + typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + } + : resolveBodyFallback() + next.treadMaterial = fallback.material + next.treadMaterialPreset = fallback.materialPreset + } + + if (!hasSide) { + const fallback = + node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string' + ? { + material: node.treadMaterial, + materialPreset: + typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + } + : resolveBodyFallback() + next.sideMaterial = fallback.material + next.sideMaterialPreset = fallback.materialPreset + } + + if (!hasRailing) { + const fallback = resolveBodyFallback() + next.railingMaterial = fallback.material + next.railingMaterialPreset = fallback.materialPreset + } + + return next +} + +function migrateRoofSurfaceMaterials(node: Record) { + const hasTop = node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string' + const hasEdge = node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string' + const hasWall = node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string' + const legacyFinish = { + material: node.material, + materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, + } + + if (!(hasTop || hasEdge || hasWall)) { + if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { + return node + } + + return { + ...node, + topMaterial: legacyFinish.material, + topMaterialPreset: legacyFinish.materialPreset, + edgeMaterial: legacyFinish.material, + edgeMaterialPreset: legacyFinish.materialPreset, + wallMaterial: legacyFinish.material, + wallMaterialPreset: legacyFinish.materialPreset, + } + } + + const next = { ...node } + + if (!hasTop) { + next.topMaterial = legacyFinish.material + next.topMaterialPreset = legacyFinish.materialPreset + } + + if (!hasEdge) { + if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { + next.edgeMaterial = node.wallMaterial + next.edgeMaterialPreset = + typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined + } else { + next.edgeMaterial = legacyFinish.material + next.edgeMaterialPreset = legacyFinish.materialPreset + } + } + + if (!hasWall) { + if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { + next.wallMaterial = node.edgeMaterial + next.wallMaterialPreset = + typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined + } else { + next.wallMaterial = legacyFinish.material + next.wallMaterialPreset = legacyFinish.materialPreset + } + } + + return next +} + +function migrateNodes(nodes: Record): { + nodes: Record + mintedMaterials: Record +} { + // Repair pre-existing corruption (null children, zero-length walls) before + // any per-type migration runs, so already-saved scenes load cleanly. + const { nodes: healed } = healSceneNodes(nodes) + const patchedNodes = { ...healed } as Record + + // Scene materials minted while moving legacy wall fields onto `node.slots`; + // merged into the scene material map by the caller (`setScene`). + const mintedMaterials: Record = {} + + // Pass 1: all node types except elevator. + // Elevator migration (migrateElevatorParent) mutates level.children to remove + // the elevator ID. If the elevator is processed before its parent level in + // Object.entries order, the level migration in this same pass would then see + // a children array that still contains the elevator ID and filter it out as + // "missing" — corrupting the level. Running elevators in a second pass after + // all levels are stable avoids the race entirely. + for (const [id, node] of Object.entries(patchedNodes)) { + // 1. Item scale migration + if (node.type === 'item' && !('scale' in node)) { + patchedNodes[id] = { ...node, scale: [1, 1, 1] } + } + // 2. Old roof to new roof + segment migration + if (node.type === 'roof' && !('children' in node)) { + const oldRoof = node + const suffix = id.includes('_') ? id.split('_')[1] : Math.random().toString(36).slice(2) + const segmentId = `rseg_${suffix}` + + const segWidth = oldRoof.length ?? 8 + const segDepth = (oldRoof.leftWidth ?? 2.2) + (oldRoof.rightWidth ?? 2.2) + const legacyRoofHeight = oldRoof.height ?? 2.5 + const segment = { + object: 'node', + id: segmentId, + type: 'roof-segment', + parentId: id, + visible: oldRoof.visible ?? true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + roofType: 'gable', + width: segWidth, + depth: segDepth, + // Schema default (0.5), NOT 0: a zero-height wall builds a flat, + // degenerate CSG brush → "Coplanar clip not handled" + NaN geometry, so + // the migrated legacy roof never renders. New roofs use 0.5 too. + wallHeight: 0.5, + pitch: getPitchFromActiveRoofHeight({ + roofType: 'gable', + width: segWidth, + depth: segDepth, + roofHeight: legacyRoofHeight, + }), + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + } + + patchedNodes[segmentId] = segment + patchedNodes[id] = { + ...oldRoof, + children: [segmentId], + } + } + + // 2b. roof-segment: guarantee a valid positive pitch (degrees). + // Saved scenes wrote `roofHeight` in metres; the schema now stores `pitch` + // in degrees. Convert the legacy field when present, and — crucially — fall + // back to the schema default for any segment that carries no usable pitch or + // roofHeight (older/partial saves). Without this, the slope-frame guard + // resolves a missing pitch to a FLAT frame, so the roof renders as a slab. + // The migration result is cast, not zod-parsed, so the schema default never + // applies on its own — this branch is the only place it lands. + if (node.type === 'roof-segment') { + const currentPitch = (node as { pitch?: unknown }).pitch + const hasValidPitch = typeof currentPitch === 'number' && currentPitch > 0 + if (!hasValidPitch) { + const { roofHeight, ...rest } = node as RoofSegmentNode & { roofHeight?: unknown } + const width = typeof node.width === 'number' ? node.width : 8 + const depth = typeof node.depth === 'number' ? node.depth : 6 + const roofType = (typeof node.roofType === 'string' ? node.roofType : 'gable') as RoofType + const derived = + typeof roofHeight === 'number' && roofHeight > 0 + ? getPitchFromActiveRoofHeight({ roofType, width, depth, roofHeight }) + : 0 + // 40° matches the RoofSegmentNode schema default. + patchedNodes[id] = { ...rest, pitch: derived > 0 ? derived : 40 } + } + } + + if (node.type === 'door') { + const normalized = normalizeDoorNode(node) + if (normalized) { + patchedNodes[id] = normalized + } + } + + if (node.type === 'window') { + const normalized = normalizeWindowNode(node) + if (normalized) { + patchedNodes[id] = normalized + } + } + + if (node.type === 'stair') { + const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) + if (normalized) { + patchedNodes[id] = normalized + } + patchedNodes[id] = migrateStairSurfaceSlots(patchedNodes[id], mintedMaterials) + } + + if (node.type === 'stair-segment') { + const normalized = normalizeStairSegmentNode(node) + if (normalized) { + patchedNodes[id] = normalized + } + } + + if (node.type === 'wall') { + patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials) + } + + if (node.type === 'slab' || node.type === 'ceiling') { + patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials) + } + + if (node.type === 'fence') { + patchedNodes[id] = migrateSingleMaterialSlots( + patchedNodes[id], + ['posts', 'infill', 'base', 'rail'], + mintedMaterials, + ) + } + + if (node.type === 'column') { + patchedNodes[id] = migrateSingleMaterialSlots( + patchedNodes[id], + ['shaft', 'base', 'capital', 'frame'], + mintedMaterials, + ) + } + + if (node.type === 'shelf') { + const normalized = normalizeShelfNode(node) + if (normalized) { + patchedNodes[id] = normalized + } + patchedNodes[id] = migrateSingleMaterialSlots( + patchedNodes[id], + ['shelves', 'frame', 'back'], + mintedMaterials, + ) + } + + // Roof-segment hosting was added in this migration cycle (the same + // pattern as shelf above). Older segments saved before the schema + // gained `children` need the field initialised so + // `createNode(chimney, segmentId)` finds an array to append to — + // without this every "Add Element" click on the roof panel results + // in an orphaned accessory (parented in scene state but never + // appended to `seg.children`, so the renderer's recursive + // `` mount never sees it). + if (node.type === 'roof-segment' && !Array.isArray((node as { children?: unknown }).children)) { + patchedNodes[id] = { ...node, children: [] } as AnyNode + } + + // Roof-hosted wall children (door / window / item) originally stored + // SEGMENT-LOCAL positions with the face yaw in rotation[1]; the + // format moved to explicit `roofFace` + FACE-LOCAL coords so the + // renderer's face frame can track segment edits live. Convert in + // place: face from the old cardinal yaw, u/v from the outer-plane + // projection, z re-based from the outer plane to the wall mid-plane. + if ( + (node.type === 'door' || node.type === 'window' || node.type === 'item') && + typeof (node as { roofSegmentId?: unknown }).roofSegmentId === 'string' && + (node as { roofFace?: unknown }).roofFace === undefined + ) { + const current = patchedNodes[id] as AnyNode & { + roofSegmentId: string + position: [number, number, number] + rotation: [number, number, number] + } + const segment = patchedNodes[current.roofSegmentId] as + | (AnyNode & { wallThickness?: number }) + | undefined + if (segment?.type === 'roof-segment') { + const tau = Math.PI * 2 + const yaw = (((current.rotation?.[1] ?? 0) % tau) + tau) % tau + const eps = 1e-3 + const face = + yaw < eps || tau - yaw < eps + ? ('front' as const) + : Math.abs(yaw - Math.PI) < eps + ? ('back' as const) + : Math.abs(yaw - Math.PI / 2) < eps + ? ('right' as const) + : Math.abs(yaw - (3 * Math.PI) / 2) < eps + ? ('left' as const) + : null + if (face) { + const { u, v, dist } = segmentPointToRoofWallFace( + segment as never, + face, + current.position, + ) + patchedNodes[id] = { + ...current, + roofFace: face, + position: [u, v, dist + (segment.wallThickness ?? 0.1) / 2], + rotation: [0, 0, 0], + } as AnyNode + } + } + } + + if (node.type === 'roof') { + patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]) + } + + // Legacy: site.children used to hold nested BuildingNode / ItemNode + // objects (see the SiteNode schema before the children-as-ids fix). + // Flatten any leftover nested children into ids, and absorb the + // embedded nodes into the flat map so the rest of the loader can + // treat the site like every other parent. + if (node.type === 'site' && Array.isArray(node.children)) { + let needsFlatten = false + const flattened: string[] = [] + for (const child of node.children) { + if (typeof child === 'string') { + flattened.push(child) + } else if (child && typeof child === 'object' && typeof child.id === 'string') { + needsFlatten = true + flattened.push(child.id) + if (!patchedNodes[child.id]) { + patchedNodes[child.id] = { ...child, parentId: id } + } + } + } + if (needsFlatten) { + patchedNodes[id] = { ...node, children: flattened } + } + } + + // Level children normalization. + // Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node + // map (e.g. elevator IDs that lived under a level before the elevator + // parent migration moved them up to building). If those dangling IDs are + // left in place, collectReachableNodeIds marks the level as having + // reachable children that don't exist, which corrupts the scene graph + // traversal and leaves the LevelNode in a broken state — making floors + // impossible to drag or delete after import. + // We intentionally do NOT filter by type prefix here; being permissive + // about which types are allowed as children prevents data loss when new + // child types are added to the schema in the future. + if (node.type === 'level') { + const rawChildren = getStringArray(node.children) + const validChildren = rawChildren.filter((childId) => { + const exists = Boolean(patchedNodes[childId]) + if (!exists) { + console.warn( + '[migrateNodes] level', + id, + 'references missing child', + childId, + '— dropping', + ) + } + return exists + }) + const levelNumber = getFiniteNumber(node.level, 0) + patchedNodes[id] = { + ...node, + level: levelNumber, + children: validChildren, + } + } + } + + // Pass 2: elevator migration. + // migrateElevatorParent mutates the parent level's children array (removes + // the elevator ID from it). Running this after Pass 1 guarantees that the + // level normalization above has already seen a clean children list — if we + // ran elevator migration inside Pass 1, the order of Object.entries + // iteration would be non-deterministic: processing an elevator before its + // parent level would mutate the level's children mid-iteration, potentially + // causing the level branch above to see a stale node reference. + for (const [id, node] of Object.entries(patchedNodes)) { + if (node.type !== 'elevator') continue + const parentMigrated = migrateElevatorParent(id, node, patchedNodes) + const normalized = normalizeElevatorNode(parentMigrated) + if (normalized) { + patchedNodes[id] = normalized + } + } + + return { nodes: patchedNodes as Record, mintedMaterials } +} + +function getNodeChildIds(node: AnyNode): AnyNodeId[] { + if (!('children' in node && Array.isArray(node.children))) { + return [] + } + + return (node.children as unknown[]) + .map((child) => { + if (typeof child === 'string') return child + if (child && typeof child === 'object' && 'id' in child && typeof child.id === 'string') { + return child.id + } + return null + }) + .filter((id): id is AnyNodeId => typeof id === 'string') +} + +function normalizeRootNodeIds( + nodes: Record, + rootNodeIds: AnyNodeId[], +): AnyNodeId[] { + const existingRootIds = rootNodeIds.filter((id) => Boolean(nodes[id])) + const siteRootIds = existingRootIds.filter((id) => nodes[id]?.type === 'site') + + if (siteRootIds.length > 0) { + return siteRootIds + } + + return existingRootIds.filter((id) => nodes[id]?.parentId === null) +} + +function collectReachableNodeIds( + nodes: Record, + rootNodeIds: AnyNodeId[], +): Set { + const reachable = new Set() + const stack = [...rootNodeIds] + const childIdsByParentId = new Map() + + for (const node of Object.values(nodes)) { + if (!node.parentId) continue + const parentId = node.parentId as AnyNodeId + const children = childIdsByParentId.get(parentId) ?? [] + children.push(node.id as AnyNodeId) + childIdsByParentId.set(parentId, children) + } + + while (stack.length > 0) { + const id = stack.pop() + if (!id || reachable.has(id)) continue + + const node = nodes[id] + if (!node) continue + + reachable.add(id) + stack.push(...getNodeChildIds(node)) + stack.push(...(childIdsByParentId.get(id) ?? [])) + } + + return reachable +} + +export type SceneState = { + // 1. The Data: A flat dictionary of all nodes + nodes: Record + + // 2. The Root: Which nodes are at the top level? + rootNodeIds: AnyNodeId[] + + // 3. The "Dirty" Set: For the Wall/Physics systems + dirtyNodes: Set + + // 4. Relational metadata — not nodes + collections: Record + materials: Record + + // 5. Read-only lock — when true all create/update/delete operations are no-ops + readOnly: boolean + setReadOnly: (readOnly: boolean) => void + + // Actions + loadScene: () => void + clearScene: () => void + unloadScene: () => void + setScene: ( + nodes: Record, + rootNodeIds: AnyNodeId[], + extra?: { + collections?: Record + materials?: Record + }, + ) => void + + markDirty: (id: AnyNodeId) => void + clearDirty: (id: AnyNodeId) => void + + createNode: (node: AnyNode, parentId?: AnyNodeId) => void + createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void + applyNodeChanges: (changes: { + create?: { node: AnyNode; parentId?: AnyNodeId }[] + update?: { id: AnyNodeId; data: Partial }[] + delete?: AnyNodeId[] + }) => void + + updateNode: (id: AnyNodeId, data: Partial) => void + updateNodes: (updates: { id: AnyNodeId; data: Partial }[]) => void + + deleteNode: (id: AnyNodeId) => void + deleteNodes: (ids: AnyNodeId[]) => void + + // Collection actions + createCollection: (name: string, nodeIds?: AnyNodeId[]) => CollectionId + deleteCollection: (id: CollectionId) => void + updateCollection: (id: CollectionId, data: Partial>) => void + addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void + removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void + + // Scene material actions + addSceneMaterial: (material: SceneMaterial) => void + updateSceneMaterial: (id: SceneMaterialId, data: Partial>) => void + removeSceneMaterial: (id: SceneMaterialId) => void +} + +// type PartializedStoreState = Pick; + +type UseSceneStore = UseBoundStore> & { + temporal: StoreApi< + TemporalState> + > +} + +const useScene: UseSceneStore = create()( + temporal( + (set, get) => ({ + // 1. Flat dictionary of all nodes + nodes: {}, + + // 2. Root node IDs + rootNodeIds: [], + + // 3. Dirty set + dirtyNodes: new Set(), + + // 4. Collections + collections: {} as Record, + materials: {} as Record, + + // 5. Read-only lock + readOnly: false, + setReadOnly: (readOnly: boolean) => set({ readOnly }), + + unloadScene: () => { + set({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + }) + }, + + clearScene: () => { + get().unloadScene() + get().loadScene() // Default scene + }, + + setScene: (nodes, rootNodeIds, extra) => { + // Apply backward compatibility migrations + const { nodes: patchedNodes, mintedMaterials } = migrateNodes(nodes) + // Scene materials minted by the wall legacy→slots migration join the + // loaded palette (existing refs win on id collision — there are none, + // ids are freshly generated). + const materials = { ...mintedMaterials, ...(extra?.materials ?? {}) } + + // Remove orphans: nodes whose parentId points to a non-existent node + const cleanedNodes = { ...patchedNodes } + for (const node of Object.values(cleanedNodes)) { + if (node.parentId && !cleanedNodes[node.parentId]) { + console.warn( + '[Scene] Removing orphan node', + node.id, + '(parentId', + node.parentId, + 'not found)', + ) + delete cleanedNodes[node.id] + } + } + + set({ + nodes: cleanedNodes, + rootNodeIds, + dirtyNodes: new Set(), + collections: extra?.collections ?? {}, + materials, + }) + + const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) + const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds) + if (normalizedRootNodeIds.length > 0) { + for (const node of Object.values(cleanedNodes)) { + if (reachableNodeIds.has(node.id as AnyNodeId)) continue + console.warn('[Scene] Removing unreachable node', node.id) + delete cleanedNodes[node.id] + } + } + + set({ + nodes: cleanedNodes, + rootNodeIds: normalizedRootNodeIds, + dirtyNodes: new Set(), + collections: extra?.collections ?? {}, + materials, + }) + // Mark all nodes as dirty to trigger re-validation + Object.values(cleanedNodes).forEach((node) => { + get().markDirty(node.id) + }) + }, + + loadScene: () => { + if (get().rootNodeIds.length > 0) { + // Assign all nodes as dirty to force re-validation + Object.values(get().nodes).forEach((node) => { + get().markDirty(node.id) + }) + return // Scene already loaded + } + + // Create hierarchy: Site → Building → Level + const level0 = LevelNode.parse({ + level: 0, + children: [], + }) + + const building = BuildingNode.parse({ + children: [level0.id], + }) + + const site = SiteNode.parse({ + children: [building.id], + }) + + // Define all nodes flat + const nodes: Record = { + [site.id]: site, + [building.id]: building, + [level0.id]: level0, + } + + // Site is the root + const rootNodeIds = [site.id] + + set({ nodes, rootNodeIds }) + }, + + markDirty: (id) => { + const node = get().nodes[id] + if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return + get().dirtyNodes.add(id) + }, + + clearDirty: (id) => { + get().dirtyNodes.delete(id) + }, + + createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), + createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]), + applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes), + + updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates), + updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]), + + // --- DELETE --- + + deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids), + + deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]), + + // --- COLLECTIONS --- + + createCollection: (name, nodeIds = []) => { + if (get().readOnly) return '' as CollectionId + const id = generateCollectionId() + const collection: Collection = { id, name, nodeIds } + set((state) => { + const nextCollections = { ...state.collections, [id]: collection } + // Denormalize: stamp collectionId onto each node + const nextNodes = { ...state.nodes } + for (const nodeId of nodeIds) { + const node = nextNodes[nodeId] + if (!node) continue + const existing = + ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? [] + nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode + } + return { collections: nextCollections, nodes: nextNodes } + }) + return id + }, + + deleteCollection: (id) => { + if (get().readOnly) return + set((state) => { + const col = state.collections[id] + const nextCollections = { ...state.collections } + delete nextCollections[id] + // Remove collectionId from all member nodes + const nextNodes = { ...state.nodes } + for (const nodeId of col?.nodeIds ?? []) { + const node = nextNodes[nodeId] + if (!(node && 'collectionIds' in node)) continue + nextNodes[nodeId] = { + ...node, + collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id), + } as AnyNode + } + return { collections: nextCollections, nodes: nextNodes } + }) + }, + + updateCollection: (id, data) => { + if (get().readOnly) return + set((state) => { + const col = state.collections[id] + if (!col) return state + return { collections: { ...state.collections, [id]: { ...col, ...data } } } + }) + }, + + addToCollection: (id, nodeId) => { + if (get().readOnly) return + set((state) => { + const col = state.collections[id] + if (!col || col.nodeIds.includes(nodeId)) return state + const nextCollections = { + ...state.collections, + [id]: { ...col, nodeIds: [...col.nodeIds, nodeId] }, + } + const node = state.nodes[nodeId] + if (!node) return { collections: nextCollections } + const existing = + ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? [] + const nextNodes = { + ...state.nodes, + [nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode, + } + return { collections: nextCollections, nodes: nextNodes } + }) + }, + + removeFromCollection: (id, nodeId) => { + if (get().readOnly) return + set((state) => { + const col = state.collections[id] + if (!col) return state + const nextCollections = { + ...state.collections, + [id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) }, + } + const node = state.nodes[nodeId] + if (!(node && 'collectionIds' in node)) return { collections: nextCollections } + const nextNodes = { + ...state.nodes, + [nodeId]: { + ...node, + collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id), + } as AnyNode, + } + return { collections: nextCollections, nodes: nextNodes } + }) + }, + + // --- SCENE MATERIALS --- + + addSceneMaterial: (material) => { + if (get().readOnly) return + set((state) => ({ + materials: { ...state.materials, [material.id]: material }, + })) + }, + + updateSceneMaterial: (id, data) => { + if (get().readOnly) return + set((state) => { + const material = state.materials[id] + if (!material) return state + return { materials: { ...state.materials, [id]: { ...material, ...data } } } + }) + }, + + removeSceneMaterial: (id) => { + if (get().readOnly) return + set((state) => { + const materials = { ...state.materials } + delete materials[id] + return { materials } + }) + }, + }), + { + partialize: (state) => { + const { nodes, rootNodeIds, collections, materials } = state + return { nodes, rootNodeIds, collections, materials } + }, + limit: 50, // Limit to last 50 actions + }, + ), +) + +export default useScene + +// Track previous temporal state lengths and node snapshot for diffing +let prevPastLength = 0 +let prevFutureLength = 0 +let prevNodesSnapshot: Record | null = null + +export function clearSceneHistory() { + resetSceneHistoryPauseDepth() + useScene.temporal.getState().clear() + prevPastLength = 0 + prevFutureLength = 0 + prevNodesSnapshot = null +} + +// Subscribe to the temporal store (Undo/Redo events) +useScene.temporal.subscribe((state) => { + const currentPastLength = state.pastStates.length + const currentFutureLength = state.futureStates.length + + // Undo: futureStates increases (state moved from past to future) + // Redo: pastStates increases while futureStates decreases (state moved from future to past) + const didUndo = currentFutureLength > prevFutureLength + const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength + + if (didUndo || didRedo) { + // Capture the previous snapshot before RAF fires + const snapshotBefore = prevNodesSnapshot + + // Defer to a microtask so the scene store has settled before we diff, + // but still mark walls/items dirty before the next paint. + queueMicrotask(() => { + const currentNodes = useScene.getState().nodes + const { markDirty } = useScene.getState() + + if (snapshotBefore) { + // Diff: only mark nodes that actually changed + for (const [id, node] of Object.entries(currentNodes) as [AnyNodeId, AnyNode][]) { + if (snapshotBefore[id] !== node) { + markDirty(id) + // Also mark parent so merged geometries update + if (node.parentId) markDirty(node.parentId as AnyNodeId) + } + } + // Nodes that were deleted (exist in prev but not current) + for (const [id, node] of Object.entries(snapshotBefore) as [AnyNodeId, AnyNode][]) { + if (!currentNodes[id]) { + const parentId = node.parentId as AnyNodeId | undefined + if (parentId) { + markDirty(parentId) + // Mark sibling nodes dirty so they can update their geometry + // (e.g. adjacent walls need to recalculate miter/junction geometry) + const parent = currentNodes[parentId] + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) { + markDirty(childId as AnyNodeId) + } + } + } + } + } + } else { + // No snapshot to diff against — fall back to marking all + for (const node of Object.values(currentNodes)) { + markDirty(node.id) + } + } + }) + } + + // Update tracked lengths and snapshot + prevPastLength = currentPastLength + prevFutureLength = currentFutureLength + prevNodesSnapshot = useScene.getState().nodes +}) diff --git a/services/pascal-editor/packages/core/src/systems/elevator/elevator-dispatch.ts b/services/pascal-editor/packages/core/src/systems/elevator/elevator-dispatch.ts new file mode 100644 index 000000000..e3f0f46d4 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/elevator/elevator-dispatch.ts @@ -0,0 +1,90 @@ +import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema' +import type { ElevatorInteractiveState } from '../../store/use-interactive' +import { resolveElevatorServiceLevels } from './elevator-service' + +type ElevatorRuntimeMap = Record + +type ResolveElevatorDispatchTargetArgs = { + elevators: ElevatorRuntimeMap + levelId: AnyNodeId + nodes: Record + requestedElevatorId: AnyNodeId +} + +function getRuntimeLevelId(runtime: ElevatorInteractiveState | undefined, elevator: ElevatorNode) { + return runtime?.currentLevelId ?? elevator.defaultLevelId ?? elevator.fromLevelId ?? null +} + +function scoreDispatchCandidate({ + elevator, + elevators, + levelId, + nodes, +}: { + elevator: ElevatorNode + elevators: ElevatorRuntimeMap + levelId: AnyNodeId + nodes: Record +}) { + if ((elevator.disabledLevelIds ?? []).includes(levelId)) return null + if ((elevator.serviceOnlyLevelIds ?? []).includes(levelId)) return null + + const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) + const targetIndex = serviceLevels.findIndex((level) => level.id === levelId) + if (targetIndex < 0) return null + + const runtime = elevators[elevator.id as AnyNodeId] + const runtimeLevelId = getRuntimeLevelId(runtime, elevator) + const currentIndex = runtimeLevelId + ? serviceLevels.findIndex((level) => level.id === runtimeLevelId) + : -1 + const resolvedCurrentIndex = currentIndex >= 0 ? currentIndex : 0 + const distance = Math.abs(targetIndex - resolvedCurrentIndex) + const queuePenalty = (runtime?.queue.length ?? 0) * 2 + (runtime?.targetLevelId ? 1 : 0) + const motionPenalty = runtime?.phase === 'moving' || runtime?.phase === 'closing' ? 0.5 : 0 + const openPenalty = runtime?.phase === 'open' || runtime?.phase === 'opening' ? 0.2 : 0 + + return distance + queuePenalty + motionPenalty + openPenalty +} + +export function resolveElevatorDispatchTarget({ + elevators, + levelId, + nodes, + requestedElevatorId, +}: ResolveElevatorDispatchTargetArgs): AnyNodeId { + const requestedElevator = nodes[requestedElevatorId] + if (!(requestedElevator?.type === 'elevator' && requestedElevator.parentId)) { + return requestedElevatorId + } + + const building = nodes[requestedElevator.parentId as AnyNodeId] + if (building?.type !== 'building') { + return requestedElevatorId + } + + let bestElevatorId: AnyNodeId | null = null + let bestScore = Number.POSITIVE_INFINITY + + for (const childId of building.children) { + const candidate = nodes[childId as AnyNodeId] + if (!(candidate?.type === 'elevator' && candidate.visible !== false)) continue + + const score = scoreDispatchCandidate({ + elevator: candidate, + elevators, + levelId, + nodes, + }) + if (score === null) continue + + const tieBreaker = candidate.id === requestedElevatorId ? -0.01 : 0 + const finalScore = score + tieBreaker + if (finalScore < bestScore) { + bestScore = finalScore + bestElevatorId = candidate.id as AnyNodeId + } + } + + return bestElevatorId ?? requestedElevatorId +} diff --git a/services/pascal-editor/packages/core/src/systems/elevator/elevator-geometry.ts b/services/pascal-editor/packages/core/src/systems/elevator/elevator-geometry.ts new file mode 100644 index 000000000..ad7fe62cd --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/elevator/elevator-geometry.ts @@ -0,0 +1,86 @@ +import type { + ElevatorDoorPanelStyle, + ElevatorDoorStyle, + ElevatorNode, + ElevatorShaftStyle, +} from '../../schema' + +export type ElevatorDoorSide = 'left' | 'right' + +const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09 + +export function getResolvedElevatorDoorStyle( + doorStyle: ElevatorNode['doorStyle'] | undefined, +): ElevatorDoorStyle { + return doorStyle ?? 'center-opening' +} + +export function getResolvedElevatorDoorPanelStyle( + doorPanelStyle: ElevatorNode['doorPanelStyle'] | undefined, +): ElevatorDoorPanelStyle { + return doorPanelStyle ?? 'glass-frame' +} + +export function getResolvedElevatorShaftStyle( + shaftStyle: ElevatorNode['shaftStyle'] | undefined, +): ElevatorShaftStyle { + return shaftStyle ?? 'solid' +} + +export function getElevatorDoorLeafSides( + doorStyle: ElevatorNode['doorStyle'] | undefined, +): ElevatorDoorSide[] { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'single-left') return ['left'] + if (resolvedDoorStyle === 'single-right') return ['right'] + return ['left', 'right'] +} + +export function getElevatorDoorLeafX( + side: ElevatorDoorSide, + openingWidth: number, + doorOpen: number, + doorStyle: ElevatorNode['doorStyle'] | undefined, +) { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'center-opening') { + const direction = side === 'left' ? -1 : 1 + return direction * (openingWidth / 4 + doorOpen * openingWidth * 0.34) + } + + const direction = resolvedDoorStyle === 'single-left' ? -1 : 1 + return direction * doorOpen * openingWidth * 0.68 +} + +export function getElevatorDoorLeafWidth( + openingWidth: number, + doorStyle: ElevatorNode['doorStyle'] | undefined, +) { + return getResolvedElevatorDoorStyle(doorStyle) === 'center-opening' + ? Math.max(openingWidth / 2 - 0.018, 0.12) + : Math.max(openingWidth - 0.018, 0.18) +} + +export function getElevatorCabWidth(node: ElevatorNode) { + return Math.max(node.width, 0.8) +} + +export function getElevatorCabDepth(node: ElevatorNode) { + return Math.max(node.depth, 0.8) +} + +export function getElevatorShaftWallThickness(node: ElevatorNode) { + return Math.max(node.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, 0.04) +} + +export function getElevatorShaftWidth(node: ElevatorNode, cabWidth = getElevatorCabWidth(node)) { + return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8) +} + +export function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDepth(node)) { + return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8) +} + +export function getElevatorCabCenterZ(node: ElevatorNode) { + return -getElevatorShaftDepth(node) / 2 + getElevatorCabDepth(node) / 2 +} diff --git a/services/pascal-editor/packages/core/src/systems/elevator/elevator-opening-sync.test.ts b/services/pascal-editor/packages/core/src/systems/elevator/elevator-opening-sync.test.ts new file mode 100644 index 000000000..ca476072f --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/elevator/elevator-opening-sync.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../../schema' +import { BuildingNode, CeilingNode, ElevatorNode, LevelNode, SlabNode } from '../../schema' +import { syncAutoElevatorOpenings } from './elevator-opening-sync' + +describe('syncAutoElevatorOpenings', () => { + test('does not add elevator holes when a manual surface hole already covers them', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const elevator = ElevatorNode.parse({ + name: 'Elevator', + parentId: building.id, + position: [2, 0, 1.5], + width: 1.6, + depth: 1.6, + }) + const buildingWithChildren = { + ...building, + children: [ground.id, upper.id, elevator.id], + } + const manualOpening: Array<[number, number]> = [ + [1, 0.5], + [3, 0.5], + [3, 2.5], + [1, 2.5], + ] + const sourceCeiling = CeilingNode.parse({ + name: 'Source Ceiling', + parentId: ground.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const upperSlab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const nodes = Object.fromEntries( + [buildingWithChildren, ground, upper, elevator, sourceCeiling, upperSlab].map((node) => [ + node.id, + node, + ]), + ) as Record + + const updates = syncAutoElevatorOpenings(nodes) + + expect(updates.find((update) => update.id === upperSlab.id)).toBeUndefined() + expect(updates.find((update) => update.id === sourceCeiling.id)).toBeUndefined() + }) + + test('adds elevator holes when an existing manual hole is too small', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const elevator = ElevatorNode.parse({ + name: 'Elevator', + parentId: building.id, + position: [2, 0, 1.5], + width: 1.6, + depth: 1.6, + }) + const buildingWithChildren = { + ...building, + children: [ground.id, upper.id, elevator.id], + } + const smallManualOpening: Array<[number, number]> = [ + [1.7, 1.2], + [2.3, 1.2], + [2.3, 1.8], + [1.7, 1.8], + ] + const upperSlab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [smallManualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const nodes = Object.fromEntries( + [buildingWithChildren, ground, upper, elevator, upperSlab].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoElevatorOpenings(nodes) + const slabUpdate = updates.find((update) => update.id === upperSlab.id) + + expect(slabUpdate?.data.holes).toHaveLength(2) + expect(slabUpdate?.data.holes?.[0]).toEqual(smallManualOpening) + expect(slabUpdate?.data.holeMetadata).toEqual([ + { source: 'manual' }, + { source: 'elevator', elevatorId: elevator.id }, + ]) + }) + + test('removes stale auto elevator holes when a manual hole overlaps the elevator opening', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const elevator = ElevatorNode.parse({ + name: 'Elevator', + parentId: building.id, + position: [2, 0, 1.5], + width: 1.6, + depth: 1.6, + }) + const buildingWithChildren = { + ...building, + children: [ground.id, upper.id, elevator.id], + } + const manualOpening: Array<[number, number]> = [ + [1, 0.5], + [3, 0.5], + [3, 2.5], + [1, 2.5], + ] + const staleAutoOpening: Array<[number, number]> = [ + [1.12, 0.62], + [2.88, 0.62], + [2.88, 2.38], + [1.12, 2.38], + ] + const upperSlab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening, staleAutoOpening], + holeMetadata: [{ source: 'manual' }, { source: 'elevator', elevatorId: elevator.id }], + }) + const nodes = Object.fromEntries( + [buildingWithChildren, ground, upper, elevator, upperSlab].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoElevatorOpenings(nodes) + const slabUpdate = updates.find((update) => update.id === upperSlab.id) + + expect(slabUpdate?.data.holes).toEqual([manualOpening]) + expect(slabUpdate?.data.holeMetadata).toEqual([{ source: 'manual' }]) + }) +}) diff --git a/services/pascal-editor/packages/core/src/systems/elevator/elevator-opening-sync.ts b/services/pascal-editor/packages/core/src/systems/elevator/elevator-opening-sync.ts new file mode 100644 index 000000000..9fe4adf91 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/elevator/elevator-opening-sync.ts @@ -0,0 +1,274 @@ +import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import type { + AnyNode, + AnyNodeId, + CeilingNode, + ElevatorNode, + SlabNode, + SurfaceHoleMetadata, +} from '../../schema' +import { resolveElevatorServiceLevels } from './elevator-service' + +type Point2D = [number, number] + +const ELEVATOR_OPENING_PADDING = 0.08 +const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09 + +function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz <= tolerance * tolerance +} + +function polygonsEqual(left: Point2D[][], right: Point2D[][]) { + if (left.length !== right.length) return false + return left.every((polygon, polygonIndex) => { + const other = right[polygonIndex] + if (!(other && polygon.length === other.length)) return false + return polygon.every((point, pointIndex) => { + const otherPoint = other[pointIndex] + if (!otherPoint) return false + return pointsEqual(point, otherPoint) + }) + }) +} + +function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]) { + if (left.length !== right.length) return false + return left.every( + (entry, index) => + entry.source === right[index]?.source && + (entry.elevatorId ?? null) === (right[index]?.elevatorId ?? null) && + (entry.stairId ?? null) === (right[index]?.stairId ?? null), + ) +} + +function normalizeExistingMetadata( + holes: Point2D[][], + metadata: SurfaceHoleMetadata[] | undefined, +): SurfaceHoleMetadata[] { + return holes.map((_, index) => metadata?.[index] ?? { source: 'manual' }) +} + +function rotateXZ(x: number, z: number, angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} + +function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) { + const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]) + if (Math.abs(cross) > tolerance) return false + const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]) + if (dot < -tolerance) return false + const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 + return dot <= lenSq + tolerance +} + +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + if (polygon.length < 3) return false + let inside = false + const [x, z] = point + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const a = polygon[i]! + const b = polygon[j]! + if (pointOnSegment(point, a, b)) return true + const intersects = + a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0] + if (intersects) inside = !inside + } + + return inside +} + +function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { + return inner.every((point) => pointInPolygon(point, outer)) +} + +function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { + return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) +} + +function getServedLevelRange(elevator: ElevatorNode, nodes: Record) { + const servedLevels = resolveElevatorServiceLevels(elevator, nodes) + if (servedLevels.length < 2) return null + + const levelNumbers = servedLevels.map((level) => level.level) + return { + max: Math.max(...levelNumbers), + min: Math.min(...levelNumbers), + sortedServedLevels: servedLevels, + } +} + +function getLevelNumber(levelId: string | null, nodes: Record) { + if (!levelId) return undefined + const node = nodes[levelId as AnyNodeId] + return node?.type === 'level' ? node.level : undefined +} + +function shouldApplyElevatorToSlab( + elevator: ElevatorNode, + slabLevelId: string, + nodes: Record, +) { + const range = getServedLevelRange(elevator, nodes) + if (!range) return false + + const slabLevel = getLevelNumber(slabLevelId, nodes) + if (slabLevel !== undefined) { + return slabLevel > range.min && slabLevel <= range.max + } + + const servedIndex = range.sortedServedLevels.findIndex((level) => level.id === slabLevelId) + return servedIndex > 0 +} + +function shouldApplyElevatorToCeiling( + elevator: ElevatorNode, + ceilingLevelId: string, + nodes: Record, +) { + const range = getServedLevelRange(elevator, nodes) + if (!range) return false + + const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) + if (ceilingLevel !== undefined) { + return ceilingLevel >= range.min && ceilingLevel < range.max + } + + const servedIndex = range.sortedServedLevels.findIndex((level) => level.id === ceilingLevelId) + return servedIndex >= 0 && servedIndex < range.sortedServedLevels.length - 1 +} + +function getElevatorOpeningPolygon(elevator: ElevatorNode): Point2D[] { + const wallThickness = Math.max( + elevator.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, + 0.04, + ) + const shaftWidth = Math.max(elevator.shaftWidth ?? elevator.width, elevator.width, 0.8) + const shaftDepth = Math.max(elevator.shaftDepth ?? elevator.depth, elevator.depth, 0.8) + const halfWidth = shaftWidth / 2 + wallThickness + ELEVATOR_OPENING_PADDING + const halfDepth = shaftDepth / 2 + wallThickness + ELEVATOR_OPENING_PADDING + const corners: Point2D[] = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + + return corners.map(([x, z]) => { + const [rotatedX, rotatedZ] = rotateXZ(x, z, elevator.rotation ?? 0) + return [elevator.position[0] + rotatedX, elevator.position[2] + rotatedZ] + }) +} + +export function syncAutoElevatorOpenings(nodes: Record) { + const elevators = Object.values(nodes).filter( + (node): node is ElevatorNode => node.type === 'elevator' && node.visible !== false, + ) + const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab') + const ceilings = Object.values(nodes).filter( + (node): node is CeilingNode => node.type === 'ceiling', + ) + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + + for (const slab of slabs) { + const slabLevelId = resolveLevelId(slab, nodes) + const existingHoles = slab.holes ?? [] + const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'elevator') + const manualHoles = preservedHoles.filter((entry) => entry.metadata.source !== 'stair') + const stairHoles = preservedHoles.filter((entry) => entry.metadata.source === 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) + + const elevatorHoles = elevators + .filter((elevator) => shouldApplyElevatorToSlab(elevator, slabLevelId, nodes)) + .map((elevator) => ({ + polygon: getElevatorOpeningPolygon(elevator), + metadata: { + elevatorId: elevator.id, + source: 'elevator' as const, + }, + })) + .filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon)) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...manualHoles.map((hole) => hole.polygon), + ...elevatorHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...manualHoles.map((hole) => ({ ...hole.metadata })), + ...elevatorHoles.map((hole) => hole.metadata), + ...stairHoles.map((hole) => ({ ...hole.metadata })), + ] + + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { + updates.push({ + id: slab.id, + data: { + holes: nextHoles, + holeMetadata: nextMetadata, + }, + }) + } + } + + for (const ceiling of ceilings) { + const ceilingLevelId = resolveLevelId(ceiling, nodes) + const existingHoles = ceiling.holes ?? [] + const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'elevator') + const manualHoles = preservedHoles.filter((entry) => entry.metadata.source !== 'stair') + const stairHoles = preservedHoles.filter((entry) => entry.metadata.source === 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) + + const elevatorHoles = elevators + .filter((elevator) => shouldApplyElevatorToCeiling(elevator, ceilingLevelId, nodes)) + .map((elevator) => ({ + polygon: getElevatorOpeningPolygon(elevator), + metadata: { + elevatorId: elevator.id, + source: 'elevator' as const, + }, + })) + .filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon)) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...manualHoles.map((hole) => hole.polygon), + ...elevatorHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...manualHoles.map((hole) => ({ ...hole.metadata })), + ...elevatorHoles.map((hole) => hole.metadata), + ...stairHoles.map((hole) => ({ ...hole.metadata })), + ] + + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { + updates.push({ + id: ceiling.id, + data: { + holes: nextHoles, + holeMetadata: nextMetadata, + }, + }) + } + } + + return updates +} diff --git a/services/pascal-editor/packages/core/src/systems/elevator/elevator-opening-system.tsx b/services/pascal-editor/packages/core/src/systems/elevator/elevator-opening-system.tsx new file mode 100644 index 000000000..2d5b7cfd6 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/elevator/elevator-opening-system.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useEffect, useRef } from 'react' +import type { AnyNode } from '../../schema' +import useScene from '../../store/use-scene' +import { syncAutoElevatorOpenings } from './elevator-opening-sync' + +function isOpeningRelevantNode(node: AnyNode | undefined) { + return ( + node?.type === 'building' || + node?.type === 'ceiling' || + node?.type === 'elevator' || + node?.type === 'level' || + node?.type === 'slab' + ) +} + +function hasOpeningRelevantNodeChange( + nextNodes: Record, + prevNodes: Record, +) { + if (nextNodes === prevNodes) return false + + const ids = new Set([...Object.keys(nextNodes), ...Object.keys(prevNodes)]) + for (const id of ids) { + const nextNode = nextNodes[id] + const prevNode = prevNodes[id] + if (nextNode === prevNode) continue + if (isOpeningRelevantNode(nextNode) || isOpeningRelevantNode(prevNode)) return true + } + + return false +} + +export const ElevatorOpeningSystem = () => { + const syncingAutoOpeningsRef = useRef(false) + + useEffect(() => { + const applyUpdates = (updates: ReturnType) => { + if (updates.length === 0) return + syncingAutoOpeningsRef.current = true + useScene.getState().updateNodes(updates) + queueMicrotask(() => { + syncingAutoOpeningsRef.current = false + }) + } + + applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes)) + + return useScene.subscribe((state, prevState) => { + if (syncingAutoOpeningsRef.current) return + if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + applyUpdates(syncAutoElevatorOpenings(state.nodes)) + }) + }, []) + + return null +} diff --git a/services/pascal-editor/packages/core/src/systems/elevator/elevator-runtime-system.tsx b/services/pascal-editor/packages/core/src/systems/elevator/elevator-runtime-system.tsx new file mode 100644 index 000000000..91c897a23 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/elevator/elevator-runtime-system.tsx @@ -0,0 +1,10 @@ +import { useFrame } from '@react-three/fiber' +import { stepElevatorRuntimes } from './elevator-runtime' + +export function ElevatorRuntimeSystem() { + useFrame(({ clock }, delta) => { + stepElevatorRuntimes(clock.getElapsedTime() * 1000, delta) + }, 2) + + return null +} diff --git a/services/pascal-editor/packages/core/src/systems/elevator/elevator-runtime.test.ts b/services/pascal-editor/packages/core/src/systems/elevator/elevator-runtime.test.ts new file mode 100644 index 000000000..43e714df4 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/elevator/elevator-runtime.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeId } from '../../schema' +import { ElevatorNode } from '../../schema' +import { + createElevatorInteractiveState, + openElevatorDoorState, + queueElevatorRequest, + stepElevatorRuntimeState, +} from './elevator-runtime' +import type { ElevatorLevelEntry } from './elevator-service' + +const groundLevelId = 'level_ground' as AnyNodeId +const upperLevelId = 'level_upper' as AnyNodeId + +const entries: ElevatorLevelEntry[] = [ + { id: groundLevelId as ElevatorLevelEntry['id'], label: '0', baseY: 0 }, + { id: upperLevelId as ElevatorLevelEntry['id'], label: '1', baseY: 2.5 }, +] + +const elevator = ElevatorNode.parse({ + speed: 10, + doorDurationMs: 100, + dwellMs: 0, +}) + +describe('elevator runtime helpers', () => { + test('queues level requests without duplicating the target', () => { + const state = createElevatorInteractiveState(groundLevelId, 0) + const queued = queueElevatorRequest(state, upperLevelId) + const duplicated = queueElevatorRequest(queued, upperLevelId) + + expect(queued.queue).toEqual([upperLevelId]) + expect(queued.requestedStops).toEqual([upperLevelId]) + expect(duplicated.queue).toEqual([upperLevelId]) + expect(duplicated.requestedStops).toEqual([upperLevelId]) + }) + + test('opens doors only when the elevator is not moving', () => { + const idle = createElevatorInteractiveState(groundLevelId, 0) + const moving = { ...idle, phase: 'moving' as const } + + expect(openElevatorDoorState(idle).phase).toBe('opening') + expect(openElevatorDoorState(moving)).toBe(moving) + }) + + test('moves to a queued level and clears the served request on arrival', () => { + const queued = queueElevatorRequest( + createElevatorInteractiveState(groundLevelId, 0), + upperLevelId, + ) + const moving = stepElevatorRuntimeState({ + defaultEntry: entries[0]!, + delta: 0.016, + elevator, + entries, + now: 0, + state: queued, + }) + + const arrived = stepElevatorRuntimeState({ + defaultEntry: entries[0]!, + delta: 1, + elevator, + entries, + now: 100, + state: moving, + }) + + const open = stepElevatorRuntimeState({ + defaultEntry: entries[0]!, + delta: 1, + elevator, + entries, + now: 200, + state: arrived, + }) + + expect(moving.phase).toBe('moving') + expect(arrived.currentLevelId).toBe(upperLevelId) + expect(arrived.phase).toBe('opening') + expect(open.phase).toBe('open') + expect(open.queue).toEqual([]) + expect(open.requestedStops).toEqual([upperLevelId]) + }) +}) diff --git a/services/pascal-editor/packages/core/src/systems/elevator/elevator-runtime.ts b/services/pascal-editor/packages/core/src/systems/elevator/elevator-runtime.ts new file mode 100644 index 000000000..7278dea0d --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/elevator/elevator-runtime.ts @@ -0,0 +1,275 @@ +import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema' +import { type ElevatorInteractiveState, useInteractive } from '../../store/use-interactive' +import useScene from '../../store/use-scene' +import { type ElevatorLevelEntry, resolveElevatorLevels } from './elevator-service' + +const EPSILON = 0.001 + +function moveToward(current: number, target: number, maxDelta: number) { + const delta = target - current + if (Math.abs(delta) <= maxDelta) return target + return current + Math.sign(delta) * maxDelta +} + +export function createElevatorInteractiveState( + levelId: AnyNodeId, + carY: number, +): ElevatorInteractiveState { + return { + currentLevelId: levelId, + targetLevelId: null, + carY, + doorOpen: 0, + phase: 'idle', + phaseStartedAt: null, + queue: [], + requestedStops: [], + } +} + +function getInitialElevatorState( + elevatorId: AnyNodeId, + nodes: Record, +): ElevatorInteractiveState | null { + const node = nodes[elevatorId] + if (node?.type !== 'elevator') return null + + const { defaultEntry } = resolveElevatorLevels(node, nodes) + if (!defaultEntry) return null + + return createElevatorInteractiveState(defaultEntry.id as AnyNodeId, defaultEntry.baseY) +} + +function ensureElevatorState( + elevatorId: AnyNodeId, + nodes: Record, +): ElevatorInteractiveState | null { + const interactive = useInteractive.getState() + const existing = interactive.elevators[elevatorId] + if (existing) return existing + + const initial = getInitialElevatorState(elevatorId, nodes) + if (!initial) return null + + interactive.initElevator(elevatorId, initial.currentLevelId as AnyNodeId, initial.carY) + return initial +} + +export function queueElevatorRequest( + state: ElevatorInteractiveState, + levelId: AnyNodeId, +): ElevatorInteractiveState { + const isAlreadyQueued = state.queue.includes(levelId) || state.targetLevelId === levelId + if (isAlreadyQueued) return state + + return { + ...state, + queue: [...state.queue, levelId], + requestedStops: state.requestedStops.includes(levelId) + ? state.requestedStops + : [...state.requestedStops, levelId], + } +} + +export function openElevatorDoorState(state: ElevatorInteractiveState): ElevatorInteractiveState { + if (!state.currentLevelId || state.phase === 'moving') return state + + return { + ...state, + phase: 'opening', + phaseStartedAt: null, + } +} + +export function requestElevatorLevel(elevatorId: AnyNodeId, levelId: AnyNodeId) { + const nodes = useScene.getState().nodes + const current = ensureElevatorState(elevatorId, nodes) + if (!current) return + + const next = queueElevatorRequest(current, levelId) + if (next === current) return + + useInteractive.getState().setElevatorState(elevatorId, next) +} + +export function openElevatorDoor(elevatorId: AnyNodeId) { + const nodes = useScene.getState().nodes + const current = ensureElevatorState(elevatorId, nodes) + if (!current) return + + const next = openElevatorDoorState(current) + if (next === current) return + + useInteractive.getState().setElevatorState(elevatorId, next) +} + +export function stepElevatorRuntimeState({ + defaultEntry, + delta, + elevator, + entries, + now, + state, +}: { + defaultEntry: ElevatorLevelEntry + delta: number + elevator: ElevatorNode + entries: ElevatorLevelEntry[] + now: number + state: ElevatorInteractiveState +}): ElevatorInteractiveState { + const currentEntry = entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry + if (currentEntry.id !== state.currentLevelId) { + return { + ...state, + currentLevelId: currentEntry.id as AnyNodeId, + carY: currentEntry.baseY, + targetLevelId: null, + phase: 'idle', + phaseStartedAt: null, + queue: [], + requestedStops: [], + doorOpen: 0, + } + } + + const targetEntry = state.targetLevelId + ? entries.find((entry) => entry.id === state.targetLevelId) + : state.queue[0] + ? entries.find((entry) => entry.id === state.queue[0]) + : null + + const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1) + const doorStep = (delta * 1000) / doorDurationMs + + switch (state.phase) { + case 'idle': { + const nextLevelId = state.queue[0] ?? null + if (!nextLevelId) { + if (state.doorOpen > EPSILON) { + return { + ...state, + doorOpen: Math.max(0, state.doorOpen - doorStep), + } + } + if (state.requestedStops.length === 0) return state + return { + ...state, + requestedStops: [], + } + } + + return { + ...state, + targetLevelId: nextLevelId, + phase: + state.doorOpen > EPSILON + ? 'closing' + : nextLevelId === state.currentLevelId + ? 'opening' + : 'moving', + phaseStartedAt: now, + } + } + + case 'closing': { + const doorOpen = Math.max(0, state.doorOpen - doorStep) + return { + ...state, + doorOpen, + phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing', + phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt, + } + } + + case 'moving': { + if (!targetEntry) { + return { + ...state, + targetLevelId: null, + phase: 'idle', + queue: [], + requestedStops: [], + } + } + + const speed = Math.max(elevator.speed ?? 2.2, 0.1) + const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta) + const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON + return { + ...state, + carY: nextY, + currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId, + phase: arrived ? 'opening' : 'moving', + phaseStartedAt: arrived ? now : state.phaseStartedAt, + } + } + + case 'opening': { + const doorOpen = Math.min(1, state.doorOpen + doorStep) + return { + ...state, + doorOpen, + phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening', + phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt, + targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId, + queue: + doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId + ? state.queue.slice(1) + : state.queue, + } + } + + case 'open': { + const elapsed = now - (state.phaseStartedAt ?? now) + if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) return state + + return { + ...state, + phase: 'closing', + phaseStartedAt: now, + targetLevelId: state.queue[0] ?? null, + } + } + } +} + +export function stepElevatorRuntimes(now: number, delta: number) { + const nodes = useScene.getState().nodes + const interactive = useInteractive.getState() + + for (const elevatorId of Object.keys(interactive.elevators)) { + const typedElevatorId = elevatorId as AnyNodeId + if (nodes[typedElevatorId]?.type !== 'elevator') { + interactive.removeElevator(typedElevatorId) + } + } + + for (const node of Object.values(nodes)) { + if (node.type !== 'elevator') continue + + const elevatorId = node.id as AnyNodeId + const { entries, defaultEntry } = resolveElevatorLevels(node, nodes) + if (!defaultEntry) continue + + const state = useInteractive.getState().elevators[elevatorId] + if (!state) { + useInteractive + .getState() + .initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) + continue + } + + const next = stepElevatorRuntimeState({ + defaultEntry, + delta, + elevator: node, + entries, + now, + state, + }) + if (next !== state) { + useInteractive.getState().setElevatorState(elevatorId, next) + } + } +} diff --git a/services/pascal-editor/packages/core/src/systems/elevator/elevator-service.ts b/services/pascal-editor/packages/core/src/systems/elevator/elevator-service.ts new file mode 100644 index 000000000..736ffe1dd --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/elevator/elevator-service.ts @@ -0,0 +1,157 @@ +import type { + AnyNode, + AnyNodeId, + CeilingNode, + ElevatorNode, + LevelNode, + WallNode, +} from '../../schema' + +export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 + +export type ElevatorLevelEntry = { + id: LevelNode['id'] + label: string + baseY: number +} + +function getBuildingLevels(elevator: ElevatorNode, nodes: Record): LevelNode[] { + const building = + elevator.parentId && nodes[elevator.parentId as AnyNodeId]?.type === 'building' + ? nodes[elevator.parentId as AnyNodeId] + : null + + if (building?.type !== 'building') return [] + + return building.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((node): node is LevelNode => node?.type === 'level') + .sort((left, right) => left.level - right.level) +} + +function findLevelIndex(levels: LevelNode[], levelId: string | null | undefined) { + if (!levelId) return -1 + return levels.findIndex((level) => level.id === levelId) +} + +function getDefaultToIndex(levels: LevelNode[], fromIndex: number) { + if (levels.length === 0) return -1 + if (fromIndex < 0) return Math.min(1, levels.length - 1) + return Math.min(fromIndex + 1, levels.length - 1) +} + +export function resolveElevatorBuildingLevels( + elevator: ElevatorNode, + nodes: Record, +): LevelNode[] { + return getBuildingLevels(elevator, nodes) +} + +export function resolveElevatorServiceLevelIds( + elevator: ElevatorNode, + nodes: Record, +): string[] { + return resolveElevatorServiceLevels(elevator, nodes).map((level) => level.id) +} + +export function resolveElevatorServiceLevels( + elevator: ElevatorNode, + nodes: Record, +): LevelNode[] { + const levels = getBuildingLevels(elevator, nodes) + if (levels.length === 0) return [] + + const hasServiceBounds = Boolean(elevator.fromLevelId || elevator.toLevelId) + let legacyServedLevels: LevelNode[] = [] + if (!hasServiceBounds && elevator.servedLevelIds && elevator.servedLevelIds.length > 0) { + const servedIds = new Set(elevator.servedLevelIds) + legacyServedLevels = levels.filter((level) => servedIds.has(level.id)) + } + + const legacyFromLevelId = legacyServedLevels[0]?.id ?? null + const legacyToLevelId = legacyServedLevels[legacyServedLevels.length - 1]?.id ?? null + const explicitFromIndex = findLevelIndex(levels, elevator.fromLevelId ?? legacyFromLevelId) + const defaultFromIndex = findLevelIndex(levels, elevator.defaultLevelId) + const fromIndex = explicitFromIndex >= 0 ? explicitFromIndex : Math.max(defaultFromIndex, 0) + const toIndex = findLevelIndex(levels, elevator.toLevelId ?? legacyToLevelId) + const resolvedToIndex = toIndex >= 0 ? toIndex : getDefaultToIndex(levels, fromIndex) + const minIndex = Math.min(fromIndex, resolvedToIndex) + const maxIndex = Math.max(fromIndex, resolvedToIndex) + + return levels.slice(minIndex, maxIndex + 1) +} + +export function getElevatorLevelHeight(levelId: string, nodes: Record): number { + const level = nodes[levelId as AnyNodeId] as LevelNode | undefined + if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT + + let maxTop = 0 + + for (const childId of level.children) { + const child = nodes[childId as AnyNodeId] + if (!child) continue + + if (child.type === 'ceiling') { + const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT + if (height > maxTop) maxTop = height + } else if (child.type === 'wall') { + const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT + if (height > maxTop) maxTop = height + } + } + + return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT +} + +export function resolveElevatorLevels( + elevator: ElevatorNode, + nodes: Record, +): { + entries: ElevatorLevelEntry[] + defaultEntry: ElevatorLevelEntry | null + shaftBaseY: number + shaftTopY: number + totalHeight: number +} { + const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + + const baseYByLevelId = new Map() + let cumulativeY = 0 + for (const level of allLevels) { + baseYByLevelId.set(level.id, cumulativeY) + cumulativeY += getElevatorLevelHeight(level.id, nodes) + } + + const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) + const entries = serviceLevels.map((level) => ({ + id: level.id, + label: String(level.level), + baseY: baseYByLevelId.get(level.id) ?? 0, + })) + + const defaultEntry = + entries.find((entry) => entry.id === elevator.defaultLevelId) ?? + entries.find((entry) => entry.id === elevator.fromLevelId) ?? + entries[0] ?? + null + const firstServedLevel = serviceLevels[0] ?? null + const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null + const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0 + const lastServedIndex = lastServedLevel + ? allLevels.findIndex((level) => level.id === lastServedLevel.id) + : -1 + const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null + const shaftTopY = nextLevel + ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY) + : lastServedLevel + ? cumulativeY + : elevator.cabHeight + 0.3 + + return { + entries, + defaultEntry, + shaftBaseY, + shaftTopY, + totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3), + } +} diff --git a/services/pascal-editor/packages/core/src/systems/fence/fence-centerline.ts b/services/pascal-editor/packages/core/src/systems/fence/fence-centerline.ts new file mode 100644 index 000000000..0cfc402cf --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/fence/fence-centerline.ts @@ -0,0 +1,61 @@ +import type { FenceNode } from '../../schema' +import { getWallCurveFrameAt, getWallCurveLength, sampleWallCenterline } from '../wall/wall-curve' +import type { Point2D } from '../wall/wall-mitering' +import { + getFenceSplineFrameAt, + getFenceSplineLength, + isSplineFence, + sampleFenceSpline, +} from './fence-spline' + +/** + * Unified fence centerline accessors. A fence is either: + * - a spline fence (`path` of >= 2 control points) → smooth Catmull-Rom, or + * - a straight / single-arc fence (`start`/`end` + optional `curveOffset`). + * + * These wrappers branch on `isSplineFence` and return the SAME shapes the wall + * arc helpers return, so every consumer (3D geometry, 2D floor-plan, length, + * handles) can sample the centerline without caring which kind it is. Wall arc + * math in `wall-curve.ts` is untouched — walls never carry a `path`. + */ + +const DEFAULT_SAMPLE_SEGMENTS = 96 + +type CurveFrame = { + point: Point2D + tangent: Point2D + normal: Point2D +} + +export function getFenceCenterlineFrameAt(fence: FenceNode, t: number): CurveFrame { + if (isSplineFence(fence) && fence.path) { + return getFenceSplineFrameAt(fence.path, t, fence.tangents) + } + return getWallCurveFrameAt(fence, t) +} + +export function sampleFenceCenterline( + fence: FenceNode, + segments = DEFAULT_SAMPLE_SEGMENTS, +): Point2D[] { + if (isSplineFence(fence) && fence.path) { + // Spread the requested sample budget across the spans so a long path still + // reads smoothly without exploding the point count. + const spanCount = Math.max(1, fence.path.length - 1) + const perSpan = Math.max(2, Math.ceil(segments / spanCount)) + return sampleFenceSpline(fence.path, fence.tangents, perSpan) + } + return sampleWallCenterline(fence, segments) +} + +export function getFenceCenterlineLength( + fence: FenceNode, + segments = DEFAULT_SAMPLE_SEGMENTS, +): number { + if (isSplineFence(fence) && fence.path) { + const spanCount = Math.max(1, fence.path.length - 1) + const perSpan = Math.max(2, Math.ceil(segments / spanCount)) + return getFenceSplineLength(fence.path, fence.tangents, perSpan) + } + return getWallCurveLength(fence, segments) +} diff --git a/services/pascal-editor/packages/core/src/systems/fence/fence-spline.test.ts b/services/pascal-editor/packages/core/src/systems/fence/fence-spline.test.ts new file mode 100644 index 000000000..9f4e50097 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/fence/fence-spline.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'bun:test' +import { + getFenceControlHandle, + getFenceSplineFrameAt, + getFenceSplineLength, + getTwoPointFenceCurveTangents, + isSplineFence, + sampleFenceSpline, +} from './fence-spline' + +describe('isSplineFence', () => { + test('false without a path or with < 2 points', () => { + expect(isSplineFence({ path: undefined })).toBe(false) + expect(isSplineFence({ path: [[0, 0]] })).toBe(false) + }) + + test('true with >= 2 points', () => { + expect( + isSplineFence({ + path: [ + [0, 0], + [1, 0], + ], + }), + ).toBe(true) + }) +}) + +describe('sampleFenceSpline', () => { + test('honors the control points as on-curve anchors', () => { + const path: Array<[number, number]> = [ + [0, 0], + [2, 2], + [4, 0], + ] + const sampled = sampleFenceSpline(path, undefined, 8) + // First and last sample equal the path endpoints exactly. + expect(sampled[0]).toEqual({ x: 0, y: 0 }) + expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 }) + // The interior control point is interpolated: some sample lands on (2, 2). + const hitsMiddle = sampled.some((p) => Math.hypot(p.x - 2, p.y - 2) < 1e-6) + expect(hitsMiddle).toBe(true) + }) + + test('two-point path with no tangents is a straight segment', () => { + expect( + sampleFenceSpline( + [ + [0, 0], + [3, 0], + ], + undefined, + 8, + ), + ).toEqual([ + { x: 0, y: 0 }, + { x: 3, y: 0 }, + ]) + }) + + test('a stored tangent bends an otherwise straight two-point span', () => { + const path: Array<[number, number]> = [ + [0, 0], + [4, 0], + ] + // Pull the first point's handle up — the span should bow off the X axis. + const sampled = sampleFenceSpline(path, [[0, 2], null], 12) + expect(sampled[0]).toEqual({ x: 0, y: 0 }) + expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 }) + const maxY = Math.max(...sampled.map((p) => Math.abs(p.y))) + expect(maxY).toBeGreaterThan(0.1) + }) + + test('generated two-point curve tangents create a gentle arc', () => { + const path: Array<[number, number]> = [ + [0, 0], + [4, 0], + ] + const sampled = sampleFenceSpline(path, getTwoPointFenceCurveTangents(path), 16) + expect(sampled[0]).toEqual({ x: 0, y: 0 }) + expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 }) + const maxY = Math.max(...sampled.map((p) => p.y)) + expect(maxY).toBeGreaterThan(0.4) + }) + + test('produces a smooth (no-cusp) curve on uneven spacing', () => { + const path: Array<[number, number]> = [ + [0, 0], + [1, 0.2], + [5, 0.3], + [6, 0], + ] + const sampled = sampleFenceSpline(path, undefined, 16) + let maxTurn = 0 + for (let i = 2; i < sampled.length; i += 1) { + const a = sampled[i - 2]! + const b = sampled[i - 1]! + const c = sampled[i]! + const t1 = Math.atan2(b.y - a.y, b.x - a.x) + const t2 = Math.atan2(c.y - b.y, c.x - b.x) + let d = Math.abs(t2 - t1) + if (d > Math.PI) d = 2 * Math.PI - d + maxTurn = Math.max(maxTurn, d) + } + expect(maxTurn).toBeLessThan(Math.PI / 2) + }) +}) + +describe('getFenceControlHandle', () => { + test('returns the stored tangent when present', () => { + expect( + getFenceControlHandle( + [ + [0, 0], + [4, 0], + ], + [[1, 2], null], + 0, + ), + ).toEqual({ x: 1, y: 2 }) + }) + + test('falls back to the automatic distance-aware tangent', () => { + const path: Array<[number, number]> = [ + [0, 0], + [3, 0], + [6, 0], + ] + expect(getFenceControlHandle(path, undefined, 1)).toEqual({ x: 1, y: 0 }) + }) +}) + +describe('getFenceSplineFrameAt', () => { + const path: Array<[number, number]> = [ + [0, 0], + [2, 0], + [4, 0], + ] + + test('t=0 / t=1 land on the endpoints', () => { + expect(getFenceSplineFrameAt(path, 0).point).toEqual({ x: 0, y: 0 }) + expect(getFenceSplineFrameAt(path, 1).point).toEqual({ x: 4, y: 0 }) + }) + + test('returns a unit tangent and perpendicular normal', () => { + const frame = getFenceSplineFrameAt(path, 0.5) + expect(Math.hypot(frame.tangent.x, frame.tangent.y)).toBeCloseTo(1, 5) + const dot = frame.tangent.x * frame.normal.x + frame.tangent.y * frame.normal.y + expect(dot).toBeCloseTo(0, 5) + }) +}) + +describe('getFenceSplineLength', () => { + test('roughly matches the straight distance for a straight path', () => { + expect( + getFenceSplineLength( + [ + [0, 0], + [3, 4], + ], + undefined, + 8, + ), + ).toBeCloseTo(5, 5) + }) + + test('a curved path is longer than its endpoint chord', () => { + const path: Array<[number, number]> = [ + [0, 0], + [2, 2], + [4, 0], + ] + const chord = Math.hypot(4, 0) + expect(getFenceSplineLength(path, undefined, 16)).toBeGreaterThan(chord) + }) +}) diff --git a/services/pascal-editor/packages/core/src/systems/fence/fence-spline.ts b/services/pascal-editor/packages/core/src/systems/fence/fence-spline.ts new file mode 100644 index 000000000..4d55cf744 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/fence/fence-spline.ts @@ -0,0 +1,257 @@ +import type { FenceNode } from '../../schema' +import type { Point2D } from '../wall/wall-mitering' + +/** + * Pure 2D spline sampling for fences whose centerline is defined by a `path` + * of control points (the "flying path" curved fence). + * + * Each control point carries an OUT-handle offset vector. When the user has + * not adjusted it, the handle defaults to a distance-aware Catmull-Rom-style + * tangent: direction comes from neighbouring points, while length is capped by + * the shorter adjacent span. When the user drags a tangent handle (stored in + * `tangents[i]`), that point's handle becomes the stored vector and the IN + * handle is its mirror, so the curve stays smooth (C1) through the point but + * bends to taste. Each span is then a cubic Bézier between consecutive points + * using their handles. + * + * Lives in `@pascal-app/core` and imports NO Three.js — the same `CurveFrame` + * shape that `wall-curve.ts` returns (point / tangent / normal) is produced + * here so the spline branch is a drop-in for the arc branch in every consumer. + */ + +const EPSILON = 1e-6 +const DEFAULT_SEGMENTS_PER_SPAN = 32 +const TWO_POINT_CURVE_SAGITTA_RATIO = 0.18 +const TWO_POINT_CURVE_MIN_SAGITTA = 0.18 +const TWO_POINT_CURVE_MAX_SAGITTA = 1.2 + +type FenceSplineLike = Pick +type TangentList = ReadonlyArray | undefined + +export function isSplineFence(fence: FenceSplineLike): boolean { + return Array.isArray(fence.path) && fence.path.length >= 2 +} + +type CurveFrame = { + point: Point2D + tangent: Point2D + normal: Point2D +} + +function toPoints(path: ReadonlyArray): Point2D[] { + return path.map(([x, y]) => ({ x, y })) +} + +function distance(a: Point2D, b: Point2D): number { + return Math.hypot(b.x - a.x, b.y - a.y) +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)) +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} + +/** + * OUT-handle offset vector for control point `index` — the stored tangent if + * the user has adjusted it, otherwise the automatic distance-aware tangent + * (endpoints duplicate the neighbour so the ends stay tangent to their single + * span). The IN handle is the negation of this. + * + * Exported so the editing UI can draw the tangent line / handle dots at the + * right place even before the user has dragged them. + */ +export function getFenceControlHandle( + path: ReadonlyArray, + tangents: TangentList, + index: number, +): Point2D { + const stored = tangents?.[index] + if (stored) { + return { x: stored[0], y: stored[1] } + } + const prev = path[index - 1] ?? path[index]! + const next = path[index + 1] ?? path[index]! + const prevDistance = distance( + { x: path[index]![0], y: path[index]![1] }, + { + x: prev[0], + y: prev[1], + }, + ) + const nextDistance = distance( + { x: path[index]![0], y: path[index]![1] }, + { + x: next[0], + y: next[1], + }, + ) + const handleLength = Math.min(prevDistance || nextDistance, nextDistance || prevDistance) / 3 + const vx = next[0] - prev[0] + const vy = next[1] - prev[1] + const len = Math.hypot(vx, vy) + if (len < EPSILON || handleLength < EPSILON) { + return { x: 0, y: 0 } + } + + return { + x: (vx / len) * handleLength, + y: (vy / len) * handleLength, + } +} + +export function getTwoPointFenceCurveTangents( + path: ReadonlyArray, +): Array<[number, number] | null> | undefined { + if (path.length !== 2) return undefined + const start = path[0]! + const end = path[1]! + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const chordLength = Math.hypot(dx, dy) + if (chordLength < EPSILON) return undefined + + const tangentX = dx / 3 + const tangentY = dy / 3 + const normalX = -dy / chordLength + const normalY = dx / chordLength + const sagitta = clamp( + chordLength * TWO_POINT_CURVE_SAGITTA_RATIO, + TWO_POINT_CURVE_MIN_SAGITTA, + TWO_POINT_CURVE_MAX_SAGITTA, + ) + const bendX = normalX * sagitta * (4 / 3) + const bendY = normalY * sagitta * (4 / 3) + + return [ + [tangentX + bendX, tangentY + bendY], + [tangentX - bendX, tangentY - bendY], + ] +} + +function cubicBezier(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, u: number): Point2D { + const mu = 1 - u + const a = mu * mu * mu + const b = 3 * mu * mu * u + const c = 3 * mu * u * u + const d = u * u * u + return { + x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, + y: a * p0.y + b * p1.y + c * p2.y + d * p3.y, + } +} + +function hasAnyTangent(tangents: TangentList): boolean { + return Array.isArray(tangents) && tangents.some((t) => t != null) +} + +/** + * Sample the spline centerline into a polyline. Control points are honored as + * on-curve anchors; `segmentsPerSpan` controls smoothness between them. + * Returns `(segmentsPerSpan * spanCount) + 1` points, first == path[0], + * last == path[-1]. + */ +export function sampleFenceSpline( + path: ReadonlyArray, + tangents?: TangentList, + segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN, +): Point2D[] { + const pts = toPoints(path) + if (pts.length === 0) return [] + if (pts.length === 1) return [pts[0]!] + // Two points with no adjusted tangents is a straight segment. + if (pts.length === 2 && !hasAnyTangent(tangents)) return [pts[0]!, pts[1]!] + + const steps = Math.max(1, Math.floor(segmentsPerSpan)) + const result: Point2D[] = [pts[0]!] + + for (let i = 0; i < pts.length - 1; i += 1) { + const p1 = pts[i]! + const p2 = pts[i + 1]! + const outHandle = getFenceControlHandle(path, tangents, i) + const nextHandle = getFenceControlHandle(path, tangents, i + 1) + // Bézier controls: leave p1 along its OUT handle, arrive at p2 along its + // IN handle (= negated OUT handle). + const c1: Point2D = { x: p1.x + outHandle.x, y: p1.y + outHandle.y } + const c2: Point2D = { x: p2.x - nextHandle.x, y: p2.y - nextHandle.y } + + for (let s = 1; s <= steps; s += 1) { + result.push(cubicBezier(p1, c1, c2, p2, s / steps)) + } + } + + return result +} + +function frameFromPolyline(points: Point2D[], t: number): CurveFrame { + if (points.length === 0) { + return { + point: { x: 0, y: 0 }, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + if (points.length === 1) { + return { + point: points[0]!, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + + const clamped = clamp01(t) + const lastIndex = points.length - 1 + const scaled = clamped * lastIndex + const lower = Math.min(lastIndex - 1, Math.floor(scaled)) + const upper = lower + 1 + const localU = scaled - lower + + const a = points[lower]! + const b = points[upper]! + const point = { + x: a.x + (b.x - a.x) * localU, + y: a.y + (b.y - a.y) * localU, + } + + const dx = b.x - a.x + const dy = b.y - a.y + const len = Math.hypot(dx, dy) + const tangent = len < EPSILON ? { x: 1, y: 0 } : { x: dx / len, y: dy / len } + + return { + point, + tangent, + normal: { x: -tangent.y, y: tangent.x }, + } +} + +/** + * Frame (point + tangent + normal) at parameter `t` in [0, 1] along the spline + * centerline. Same return shape as `getWallCurveFrameAt` so it is a drop-in for + * the arc branch. `t` is uniform over the sampled polyline (arc length is not + * reparameterised — adequate for marching posts / rails and far cheaper). + */ +export function getFenceSplineFrameAt( + path: ReadonlyArray, + t: number, + tangents?: TangentList, + segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN, +): CurveFrame { + return frameFromPolyline(sampleFenceSpline(path, tangents, segmentsPerSpan), t) +} + +/** Total polyline length of the sampled spline centerline. */ +export function getFenceSplineLength( + path: ReadonlyArray, + tangents?: TangentList, + segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN, +): number { + const points = sampleFenceSpline(path, tangents, segmentsPerSpan) + let total = 0 + for (let i = 1; i < points.length; i += 1) { + total += distance(points[i - 1]!, points[i]!) + } + return total +} diff --git a/services/pascal-editor/packages/core/src/systems/stair/stair-footprint.ts b/services/pascal-editor/packages/core/src/systems/stair/stair-footprint.ts new file mode 100644 index 000000000..cf41533da --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/stair/stair-footprint.ts @@ -0,0 +1,223 @@ +import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' + +/** + * Stair footprint geometry shared by the slab-opening sync and the + * alignment-anchor adapters. A stair has no single box footprint: straight + * stairs are a cumulative chain of `stair-segment` children, curved / spiral + * stairs are an annular sector stored entirely on the parent. Both reduce to + * an XZ bounding box here so callers that only need "where does the stair sit + * in plan" (alignment guides) don't have to re-walk the geometry. + * + * All math is in the building-local XZ frame, matching `node.position`. + */ + +export type StairFootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number } + +type SegmentTransform = { + position: [number, number, number] + rotation: number +} + +/** + * XZ rotation in the stair geometry convention (equivalent to rotating by + * `-angle` in standard math): positive `angle` turns local +Z toward +X. Every + * stair helper — slab openings, floor-plan emitter, this footprint — shares it, + * so anchors line up with the rendered stair. + */ +export function rotateXZ(x: number, z: number, angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} + +/** + * Cumulative per-segment transforms for a straight (segment-chained) stair. + * Each flight attaches to the previous segment's end; `attachmentSide` rotates + * the chain ±90° (left / right) or continues straight (front). Positions are in + * the stair's local frame (before the stair's own `position` / `rotation`). + */ +export function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] { + const transforms: SegmentTransform[] = [] + let currentX = 0 + let currentY = 0 + let currentZ = 0 + let currentRot = 0 + + for (let index = 0; index < segments.length; index++) { + const segment = segments[index] + if (!segment) continue + + if (index === 0) { + transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot }) + continue + } + + const previous = segments[index - 1] + if (!previous) continue + + let attachX = 0 + let attachZ = 0 + let rotationDelta = 0 + + switch (segment.attachmentSide) { + case 'front': + attachX = 0 + attachZ = previous.length + break + case 'left': + attachX = previous.width / 2 + attachZ = previous.length / 2 + rotationDelta = Math.PI / 2 + break + case 'right': + attachX = -previous.width / 2 + attachZ = previous.length / 2 + rotationDelta = -Math.PI / 2 + break + } + + const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot) + currentX += deltaX + currentY += previous.height + currentZ += deltaZ + currentRot += rotationDelta + + transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot }) + } + + return transforms +} + +function emptyBox(): StairFootprintAABB { + return { + minX: Number.POSITIVE_INFINITY, + minZ: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxZ: Number.NEGATIVE_INFINITY, + } +} + +/** Grow `box` to include the world-plan point produced by rotating the + * stair-local point by the stair's rotation and offsetting by its position. */ +function extendByLocal(box: StairFootprintAABB, stair: StairNode, localX: number, localZ: number) { + const [wx, wz] = rotateXZ(localX, localZ, stair.rotation ?? 0) + const x = stair.position[0] + wx + const z = stair.position[2] + wz + if (x < box.minX) box.minX = x + if (x > box.maxX) box.maxX = x + if (z < box.minZ) box.minZ = z + if (z > box.maxZ) box.maxZ = z +} + +function finiteBox(box: StairFootprintAABB): StairFootprintAABB | null { + return Number.isFinite(box.minX) && Number.isFinite(box.minZ) ? box : null +} + +/** Bounding box of a straight stair's segment chain, walking the children. */ +function straightStairAABB( + stair: StairNode, + nodes: Readonly>, +): StairFootprintAABB | null { + const segments = (stair.children ?? []) + .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) + .filter( + (segment): segment is StairSegmentNode => + segment?.type === 'stair-segment' && segment.visible !== false, + ) + if (segments.length === 0) return null + + const transforms = computeSegmentTransforms(segments) + const box = emptyBox() + segments.forEach((segment, index) => { + const transform = transforms[index] + if (!transform) return + const halfWidth = segment.width / 2 + // Segment-local footprint: X across the flight, Z along the run from the + // attachment edge (0) to the far edge (length). + for (const [cornerX, cornerZ] of [ + [-halfWidth, 0], + [halfWidth, 0], + [halfWidth, segment.length], + [-halfWidth, segment.length], + ] as const) { + const [offsetX, offsetZ] = rotateXZ(cornerX, cornerZ, transform.rotation) + extendByLocal(box, stair, transform.position[0] + offsetX, transform.position[2] + offsetZ) + } + }) + return finiteBox(box) +} + +const ARC_SAMPLES = 48 + +function getSpiralLandingSweep(stair: StairNode, sweepAngle: number) { + if ((stair.topLandingMode ?? 'none') !== 'integrated') return 0 + + const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9) + const width = Math.max(stair.width ?? 1, 0.4) + const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8)) + + return ( + Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) * + Math.sign(sweepAngle || 1) + ) +} + +/** Bounding box of a curved / spiral stair's annular sector (plus the + * integrated spiral top landing when present). */ +function arcStairAABB(stair: StairNode): StairFootprintAABB | null { + const isSpiral = stair.stairType === 'spiral' + const minInnerRadius = isSpiral ? 0.05 : 0.2 + const innerRadius = Math.max(minInnerRadius, stair.innerRadius ?? (isSpiral ? 0.2 : 0.9)) + const width = Math.max(stair.width ?? 1, 0.4) + const outerRadius = innerRadius + width + + const rawSweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2) + let sweep = rawSweep + // A full revolution would make the arc degenerate; clamp just under 2π the + // same way the floor-plan emitter does so the sampled box stays correct. + if (Math.abs(sweep) >= Math.PI * 2) sweep = Math.sign(sweep || 1) * (Math.PI * 2 - 0.001) + const half = sweep / 2 + + const box = emptyBox() + // Sample both rims across the sweep — the extremes can fall on either the + // arc ends or an axis crossing in between, so we need the full sweep. + for (let step = 0; step <= ARC_SAMPLES; step += 1) { + const angle = -half + (sweep * step) / ARC_SAMPLES + const cos = Math.cos(angle) + const sin = Math.sin(angle) + extendByLocal(box, stair, cos * innerRadius, sin * innerRadius) + extendByLocal(box, stair, cos * outerRadius, sin * outerRadius) + } + + // Integrated spiral top landing renders as an angular extension of the + // annular stair body, not as a rectangular box outside the outer rim. + if (isSpiral && stair.topLandingMode === 'integrated') { + const landingSweep = getSpiralLandingSweep(stair, rawSweep) + const landingSteps = Math.max(1, Math.ceil(Math.abs(landingSweep) / (Math.PI / 24))) + for (let step = 0; step <= landingSteps; step += 1) { + const angle = rawSweep / 2 + (landingSweep * step) / landingSteps + const cos = Math.cos(angle) + const sin = Math.sin(angle) + extendByLocal(box, stair, cos * innerRadius, sin * innerRadius) + extendByLocal(box, stair, cos * outerRadius, sin * outerRadius) + } + } + + return finiteBox(box) +} + +/** + * XZ bounding box of a stair's plan footprint, or null when it can't be + * determined (a straight stair whose segment children aren't in `nodes`). + * Straight stairs need the children to walk the flight chain; curved / spiral + * stairs are derived from the parent alone, so `nodes` is optional for them. + */ +export function stairFootprintAABB( + stair: StairNode, + nodes?: Readonly>, +): StairFootprintAABB | null { + if ((stair.stairType ?? 'straight') === 'straight') { + return nodes ? straightStairAABB(stair, nodes) : null + } + return arcStairAABB(stair) +} diff --git a/services/pascal-editor/packages/core/src/systems/stair/stair-opening-preview.test.ts b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-preview.test.ts new file mode 100644 index 000000000..94aa55dd0 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-preview.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../../schema' +import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { + getNodesWithLiveStairOpeningInputs, + hasLiveStairOpeningInputs, +} from './stair-opening-preview' +import { syncAutoStairOpenings } from './stair-opening-sync' + +describe('stair opening previews', () => { + test('computes auto openings from live stair transforms', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const slab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_live', + width: 1, + length: 3, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_live', + name: 'Live Stair', + parentId: ground.id, + position: [1, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, slab, stair, { ...segment, parentId: stair.id }].map((node) => [ + node.id, + node, + ]), + ) as Record + const liveTransforms = new Map([ + [stair.id, { position: [3, 0, 0.2] as [number, number, number], rotation: 0 }], + ]) + const liveOverrides = new Map>() + + expect(hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, new Set())).toBe(true) + + const previewNodes = getNodesWithLiveStairOpeningInputs( + nodes, + liveTransforms, + liveOverrides, + new Set(), + ) + const updates = syncAutoStairOpenings(previewNodes) + const hole = updates.find((update) => update.id === slab.id)?.data.holes?.[0] + + expect(hole).toBeDefined() + expect(Math.max(...hole!.map(([x]) => x))).toBeGreaterThan(3.4) + }) + + test('ignores its own live surface overrides as preview inputs', () => { + const slab = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + }) + const nodes = { [slab.id]: slab } as Record + const liveOverrides = new Map>([ + [ + slab.id, + { + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + }, + ], + ]) + + expect(hasLiveStairOpeningInputs(nodes, new Map(), liveOverrides, new Set([slab.id]))).toBe( + false, + ) + }) +}) diff --git a/services/pascal-editor/packages/core/src/systems/stair/stair-opening-preview.ts b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-preview.ts new file mode 100644 index 000000000..24b464189 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-preview.ts @@ -0,0 +1,120 @@ +import type { AnyNode, AnyNodeId, CeilingNode, SlabNode } from '../../schema' +import useLiveNodeOverrides, { type LiveNodeOverrides } from '../../store/use-live-node-overrides' +import type { LiveTransform } from '../../store/use-live-transforms' +import useScene from '../../store/use-scene' + +type SurfaceOpeningUpdate = { + id: AnyNodeId + data: Partial +} + +const SURFACE_OPENING_FIELDS = ['holes', 'holeMetadata'] as const + +function isSurface(node: AnyNode | undefined): node is SlabNode | CeilingNode { + return node?.type === 'slab' || node?.type === 'ceiling' +} + +function isStairOpeningInputNode(node: AnyNode | undefined) { + return node?.type === 'stair' || node?.type === 'stair-segment' +} + +function omitPreviewSurfaceFields(override: LiveNodeOverrides) { + const next = { ...override } + for (const field of SURFACE_OPENING_FIELDS) { + delete next[field] + } + return next +} + +export function hasLiveStairOpeningInputs( + nodes: Record, + liveTransforms: ReadonlyMap, + liveOverrides: ReadonlyMap, + previewSurfaceIds: ReadonlySet, +) { + for (const nodeId of liveTransforms.keys()) { + if (nodes[nodeId]?.type === 'stair') return true + } + + for (const [nodeId, override] of liveOverrides) { + if (previewSurfaceIds.has(nodeId)) continue + if (Object.keys(override).length > 0 && isStairOpeningInputNode(nodes[nodeId])) return true + } + + return false +} + +export function getNodesWithLiveStairOpeningInputs( + nodes: Record, + liveTransforms: ReadonlyMap, + liveOverrides: ReadonlyMap, + previewSurfaceIds: ReadonlySet, +) { + const nextNodes: Record = { ...nodes } + + for (const [nodeId, override] of liveOverrides) { + const node = nextNodes[nodeId] + if (!node) continue + + const values = previewSurfaceIds.has(nodeId) ? omitPreviewSurfaceFields(override) : override + if (Object.keys(values).length === 0) continue + nextNodes[nodeId] = { ...node, ...values } as AnyNode + } + + for (const [nodeId, transform] of liveTransforms) { + const node = nextNodes[nodeId] + if (node?.type !== 'stair') continue + nextNodes[nodeId] = { + ...node, + position: transform.position, + rotation: transform.rotation, + } + } + + return nextNodes +} + +export function createSurfaceOpeningPreviewController() { + const previewSurfaceIds = new Set() + + const clearSurface = (id: AnyNodeId) => { + useLiveNodeOverrides.getState().clearFields(id, SURFACE_OPENING_FIELDS) + useScene.getState().markDirty(id) + } + + return { + previewSurfaceIds, + apply(updates: SurfaceOpeningUpdate[]) { + const scene = useScene.getState() + const nextSurfaceIds = new Set() + + for (const update of updates) { + const node = scene.nodes[update.id] + if (!isSurface(node)) continue + if (!('holes' in update.data || 'holeMetadata' in update.data)) continue + + nextSurfaceIds.add(update.id) + useLiveNodeOverrides.getState().set(update.id, { + holes: update.data.holes ?? [], + holeMetadata: update.data.holeMetadata ?? [], + }) + scene.markDirty(update.id) + } + + for (const id of previewSurfaceIds) { + if (!nextSurfaceIds.has(id)) clearSurface(id) + } + + previewSurfaceIds.clear() + for (const id of nextSurfaceIds) { + previewSurfaceIds.add(id) + } + }, + clear() { + for (const id of previewSurfaceIds) { + clearSurface(id) + } + previewSurfaceIds.clear() + }, + } +} diff --git a/services/pascal-editor/packages/core/src/systems/stair/stair-opening-sync.test.ts b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-sync.test.ts new file mode 100644 index 000000000..9068c029f --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -0,0 +1,555 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../../schema' +import { + BuildingNode, + CeilingNode, + LevelNode, + SlabNode, + StairNode, + StairSegmentNode, +} from '../../schema' +import { syncAutoStairOpenings } from './stair-opening-sync' + +describe('syncAutoStairOpenings', () => { + test('only applies stair holes to destination slabs that overlap the opening', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const bedroomSlab = SlabNode.parse({ + name: 'Bedroom Slab', + parentId: upper.id, + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [ + building, + ground, + upper, + landingSlab, + bedroomSlab, + stair, + { ...segment, parentId: stair.id }, + ].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const bedroomUpdate = updates.find((update) => update.id === bedroomSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(1) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + expect(bedroomUpdate).toBeUndefined() + }) + + test('applies stair holes to a later destination slab when the configured offset overhangs the slab edge', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_edge', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_edge', + name: 'Edge Stair', + parentId: ground.id, + position: [2, 0, 0], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + openingOffset: 0.08, + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const hole = landingUpdate?.data.holes?.[0] + + expect(hole).toBeDefined() + expect(Math.min(...hole!.map(([, z]) => z))).toBeCloseTo(-0.08) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('does not apply stair holes to slabs on another building with a matching level number', () => { + const buildingA = BuildingNode.parse({ name: 'Building A' }) + const groundA = LevelNode.parse({ name: 'Ground A', level: 0, parentId: buildingA.id }) + const upperA = LevelNode.parse({ name: 'Upper A', level: 1, parentId: buildingA.id }) + const buildingB = BuildingNode.parse({ name: 'Building B' }) + const upperB = LevelNode.parse({ name: 'Upper B', level: 1, parentId: buildingB.id }) + const slabA = SlabNode.parse({ + name: 'Upper A Slab', + parentId: upperA.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const slabB = SlabNode.parse({ + name: 'Upper B Slab', + parentId: upperB.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_scoped', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_scoped', + name: 'Scoped Stair', + parentId: groundA.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: groundA.id, + toLevelId: upperA.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [ + buildingA, + groundA, + upperA, + buildingB, + upperB, + slabA, + slabB, + stair, + { ...segment, parentId: stair.id }, + ].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + + expect(updates.find((update) => update.id === slabA.id)?.data.holes).toHaveLength(1) + expect(updates.find((update) => update.id === slabB.id)).toBeUndefined() + }) + + test('uses the parent level when a stair has stale from-level data', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 8], + [0, 8], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_stale_from', + width: 1, + length: 6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_stale_from', + name: 'Stale From Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: 'default', + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const hole = landingUpdate?.data.holes?.[0] + + expect(hole).toBeDefined() + expect(Math.min(...hole!.map(([, z]) => z))).toBeGreaterThan(0.9) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('infers the destination level when a destination stair has blank level fields', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 8], + [0, 8], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_blank_levels', + width: 1, + length: 6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_blank_levels', + name: 'Blank Level Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: '', + toLevelId: '', + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(1) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('infers the destination level when a destination stair targets its source level', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 8], + [0, 8], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_self_target', + width: 1, + length: 6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_self_target', + name: 'Self Target Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: ground.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(1) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('does not add stair holes when a manual surface hole already covers them', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const manualOpening: Array<[number, number]> = [ + [1.0, 0.0], + [3.0, 0.0], + [3.0, 3.0], + [1.0, 3.0], + ] + const sourceCeiling = CeilingNode.parse({ + name: 'Source Ceiling', + parentId: ground.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [ + building, + ground, + upper, + sourceCeiling, + landingSlab, + stair, + { ...segment, parentId: stair.id }, + ].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + + expect(updates.find((update) => update.id === landingSlab.id)).toBeUndefined() + expect(updates.find((update) => update.id === sourceCeiling.id)).toBeUndefined() + }) + + test('adds stair holes when an existing manual hole is too small', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const smallManualOpening: Array<[number, number]> = [ + [1.8, 1.6], + [2.2, 1.6], + [2.2, 2.1], + [1.8, 2.1], + ] + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [smallManualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(2) + expect(landingUpdate?.data.holes?.[0]).toEqual(smallManualOpening) + expect(landingUpdate?.data.holeMetadata).toEqual([ + { source: 'manual' }, + { source: 'stair', stairId: stair.id }, + ]) + }) + + test('removes stale auto stair holes when a manual hole overlaps the stair opening', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const manualOpening: Array<[number, number]> = [ + [1.0, 0.0], + [3.0, 0.0], + [3.0, 3.0], + [1.0, 3.0], + ] + const staleAutoOpening: Array<[number, number]> = [ + [1.5, 1], + [2.5, 1], + [2.5, 2.8], + [1.5, 2.8], + ] + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening, staleAutoOpening], + holeMetadata: [{ source: 'manual' }, { source: 'stair', stairId: 'stair_main' }], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toEqual([manualOpening]) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'manual' }]) + }) + + test('does not add a separate rectangular hole for an integrated spiral top landing', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [-4, -4], + [4, -4], + [4, 4], + [-4, 4], + ], + }) + const stair = StairNode.parse({ + id: 'stair_spiral_landing', + name: 'Spiral Landing Stair', + parentId: ground.id, + position: [0, 0, 0], + rotation: Math.PI / 2, + stairType: 'spiral', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + innerRadius: 0.35, + width: 1.2, + sweepAngle: Math.PI * 1.6, + topLandingMode: 'integrated', + topLandingDepth: 1.1, + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const holes = landingUpdate?.data.holes ?? [] + const rectangularHoles = holes.filter((hole) => hole.length === 4) + + expect(holes).toHaveLength(1) + expect(rectangularHoles).toHaveLength(0) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) +}) diff --git a/services/pascal-editor/packages/core/src/systems/stair/stair-opening-sync.ts b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-sync.ts new file mode 100644 index 000000000..36b1c2ffa --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-sync.ts @@ -0,0 +1,802 @@ +import { resolveBuildingForLevel, resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import { type Point2D, polygonContainsPolygon, polygonsOverlap } from '../../lib/polygon-relations' +import type { + AnyNode, + AnyNodeId, + CeilingNode, + SlabNode, + StairNode, + StairSegmentNode, + SurfaceHoleMetadata, +} from '../../schema' +import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' +import { computeSegmentTransforms, rotateXZ } from './stair-footprint' + +type SegmentTransform = { + position: [number, number, number] + rotation: number +} + +type StraightStairLayout = { + segment: StairSegmentNode + transform: SegmentTransform + topElevation: number +} + +type AxisAlignedRect = { + minX: number + maxX: number + minZ: number + maxZ: number +} + +const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8 +const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35 +const STAIR_SLAB_OPENING_TIGHTENING = 0 + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz <= tolerance * tolerance +} + +function polygonsEqual(left: Point2D[][], right: Point2D[][]) { + if (left.length !== right.length) return false + return left.every((polygon, polygonIndex) => { + const other = right[polygonIndex] + if (!(other && polygon.length === other.length)) return false + return polygon.every((point, pointIndex) => { + const otherPoint = other[pointIndex] + if (!otherPoint) return false + return pointsEqual(point, otherPoint) + }) + }) +} + +function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]) { + if (left.length !== right.length) return false + return left.every( + (entry, index) => + entry.source === right[index]?.source && + (entry.elevatorId ?? null) === (right[index]?.elevatorId ?? null) && + (entry.stairId ?? null) === (right[index]?.stairId ?? null), + ) +} + +function normalizeExistingMetadata( + holes: Point2D[][], + metadata: SurfaceHoleMetadata[] | undefined, +): SurfaceHoleMetadata[] { + return holes.map((_, index) => metadata?.[index] ?? { source: 'manual' }) +} + +// (Removing expandPolygonRadially in favor of geometric expansion inside the polygon generators) +// `rotateXZ` + `computeSegmentTransforms` are shared with the alignment-anchor +// footprint via `./stair-footprint` so both derive the chain identically. + +function getLevelNumber(levelId: string | null, nodes: Record) { + if (!levelId) return undefined + const node = nodes[levelId as AnyNodeId] + return node?.type === 'level' ? node.level : undefined +} + +function getLevelBuildingId(levelId: string | null, nodes: Record) { + if (!levelId) return null + return resolveBuildingForLevel(levelId as AnyNodeId, nodes as Record) +} + +function normalizeLevelId(levelId: string | null | undefined, nodes: Record) { + if (!levelId) return null + return nodes[levelId as AnyNodeId]?.type === 'level' ? levelId : null +} + +function getBuildingLevels(buildingId: string | null, nodes: Record) { + const building = buildingId ? nodes[buildingId as AnyNodeId] : null + if (building?.type !== 'building') return [] + + const levels = new Map>() + for (const childId of building.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'level') levels.set(child.id, child) + } + for (const candidate of Object.values(nodes)) { + if (candidate?.type === 'level' && candidate.parentId === building.id) { + levels.set(candidate.id, candidate) + } + } + + return Array.from(levels.values()).sort((left, right) => left.level - right.level) +} + +function inferSourceLevelForDestination( + destinationLevelId: string | null, + nodes: Record, +) { + if (!destinationLevelId) return null + const destination = nodes[destinationLevelId as AnyNodeId] + if (destination?.type !== 'level') return null + + const buildingId = getLevelBuildingId(destinationLevelId, nodes) + return ( + getBuildingLevels(buildingId, nodes) + .filter((level) => level.level < destination.level) + .at(-1)?.id ?? null + ) +} + +function inferDestinationLevelForSource( + sourceLevelId: string | null, + nodes: Record, +) { + if (!sourceLevelId) return null + const source = nodes[sourceLevelId as AnyNodeId] + if (source?.type !== 'level') return null + + const buildingId = getLevelBuildingId(sourceLevelId, nodes) + return ( + getBuildingLevels(buildingId, nodes).find((level) => level.level > source.level)?.id ?? null + ) +} + +function levelsShareBuilding( + leftLevelId: string | null, + rightLevelId: string | null, + nodes: Record, +) { + if (!(leftLevelId && rightLevelId)) return true + const leftBuildingId = getLevelBuildingId(leftLevelId, nodes) + const rightBuildingId = getLevelBuildingId(rightLevelId, nodes) + return !(leftBuildingId && rightBuildingId && leftBuildingId !== rightBuildingId) +} + +function isInStairBuildingScope( + stair: StairNode, + surfaceLevelId: string, + nodes: Record, +) { + const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) + const fromBuildingId = getLevelBuildingId(fromLevelId, nodes) + const toBuildingId = getLevelBuildingId(toLevelId, nodes) + const surfaceBuildingId = getLevelBuildingId(surfaceLevelId, nodes) + + if (fromBuildingId && toBuildingId && fromBuildingId !== toBuildingId) return false + if (fromBuildingId && surfaceBuildingId && fromBuildingId !== surfaceBuildingId) return false + if (toBuildingId && surfaceBuildingId && toBuildingId !== surfaceBuildingId) return false + + return true +} + +function getResolvedStairLevelIds(stair: StairNode, nodes: Record) { + const parentLevelId = normalizeLevelId(resolveLevelId(stair, nodes), nodes) + const explicitToLevelId = normalizeLevelId(stair.toLevelId, nodes) + const fromLevelId = + normalizeLevelId(stair.fromLevelId, nodes) ?? + parentLevelId ?? + inferSourceLevelForDestination(explicitToLevelId, nodes) + const explicitToLevelIsUsable = + explicitToLevelId && + explicitToLevelId !== fromLevelId && + levelsShareBuilding(fromLevelId, explicitToLevelId, nodes) + const toLevelId = explicitToLevelIsUsable + ? explicitToLevelId + : inferDestinationLevelForSource(fromLevelId, nodes) + return { fromLevelId, toLevelId } +} + +function resolveStraightSegments(stair: StairNode, nodes: Record) { + return (stair.children ?? []) + .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) + .filter( + (segment): segment is StairSegmentNode => + segment?.type === 'stair-segment' && segment.visible !== false, + ) +} + +function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Point2D { + const [worldX, worldZ] = rotateXZ(localX, localZ, stair.rotation ?? 0) + return [stair.position[0] + worldX, stair.position[2] + worldZ] +} + +function getStraightStairLayouts( + stair: StairNode, + nodes: Record, +): StraightStairLayout[] { + const segments = resolveStraightSegments(stair, nodes) + const transforms = computeSegmentTransforms(segments) + + return segments.map((segment, index) => { + const transform = transforms[index] ?? { + position: [0, 0, 0] as [number, number, number], + rotation: 0, + } + + return { + segment, + transform, + topElevation: transform.position[1] + (segment.segmentType === 'stair' ? segment.height : 0), + } + }) +} + +function getStraightSegmentFootprintPolygon( + stair: StairNode, + layout: StraightStairLayout, +): Point2D[] { + return getStraightSegmentSlicePolygon(stair, layout, 0, layout.segment.length) +} + +function getStraightSegmentLocalSlicePolygon( + layout: StraightStairLayout, + startAlong: number, + endAlong: number, +): Point2D[] { + const { segment, transform } = layout + const clampedStart = clamp(startAlong, 0, segment.length) + const clampedEnd = clamp(endAlong, clampedStart, segment.length) + const sliceLength = Math.max(clampedEnd - clampedStart, 1e-4) + const sliceCenterAlong = clampedStart + sliceLength / 2 + const [centerOffsetX, centerOffsetZ] = rotateXZ(0, sliceCenterAlong, transform.rotation) + const centerX = transform.position[0] + centerOffsetX + const centerZ = transform.position[2] + centerOffsetZ + const halfWidth = segment.width / 2 + const halfLength = sliceLength / 2 + const corners: Point2D[] = [ + [-halfWidth, -halfLength], + [halfWidth, -halfLength], + [halfWidth, halfLength], + [-halfWidth, halfLength], + ] + + return corners.map(([localWidth, localLength]) => { + const [offsetX, offsetZ] = rotateXZ(localWidth, localLength, transform.rotation) + return [centerX + offsetX, centerZ + offsetZ] + }) +} + +function getStraightSegmentSlicePolygon( + stair: StairNode, + layout: StraightStairLayout, + startAlong: number, + endAlong: number, +): Point2D[] { + return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) => + toWorldPlanPoint(stair, x, z), + ) +} + +function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNode) { + const treadDepth = Math.max( + 0.2, + segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1), + ) + return Math.min(segment.length, Math.max(treadDepth * 10, segment.length * 0.8, 3.0)) +} + +function polygonArea(points: Point2D[]) { + let area = 0 + for (let index = 0; index < points.length; index += 1) { + const current = points[index] + const next = points[(index + 1) % points.length] + if (!(current && next)) continue + area += current[0] * next[1] - next[0] * current[1] + } + return area / 2 +} + +function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { + return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) +} + +function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null { + if (polygon.length < 4) return null + const xs = polygon.map(([x]) => x) + const zs = polygon.map(([, z]) => z) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minZ = Math.min(...zs) + const maxZ = Math.max(...zs) + if (!(maxX > minX && maxZ > minZ)) return null + return { minX, maxX, minZ, maxZ } +} + +function expandRect(rect: AxisAlignedRect, offset: number): AxisAlignedRect { + if (offset <= 1e-6) { + return rect + } + + return { + minX: rect.minX - offset, + maxX: rect.maxX + offset, + minZ: rect.minZ - offset, + maxZ: rect.maxZ + offset, + } +} + +function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] { + if (rects.length === 0) return [] + + const xs = Array.from( + new Set( + rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))), + ), + ).sort((a, b) => a - b) + const zs = Array.from( + new Set( + rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))), + ), + ).sort((a, b) => a - b) + if (xs.length < 2 || zs.length < 2) return [] + + const occupied = new Set() + for (let xi = 0; xi < xs.length - 1; xi += 1) { + for (let zi = 0; zi < zs.length - 1; zi += 1) { + const cx = (xs[xi]! + xs[xi + 1]!) / 2 + const cz = (zs[zi]! + zs[zi + 1]!) / 2 + if ( + rects.some((rect) => cx > rect.minX && cx < rect.maxX && cz > rect.minZ && cz < rect.maxZ) + ) { + occupied.add(`${xi}:${zi}`) + } + } + } + + const edgeMap = new Map() + const addEdge = (start: Point2D, end: Point2D) => { + edgeMap.set(`${start[0]},${start[1]}`, end) + } + + for (let xi = 0; xi < xs.length - 1; xi += 1) { + for (let zi = 0; zi < zs.length - 1; zi += 1) { + if (!occupied.has(`${xi}:${zi}`)) continue + + const x0 = xs[xi]! + const x1 = xs[xi + 1]! + const z0 = zs[zi]! + const z1 = zs[zi + 1]! + + if (!occupied.has(`${xi}:${zi - 1}`)) addEdge([x0, z0], [x1, z0]) + if (!occupied.has(`${xi + 1}:${zi}`)) addEdge([x1, z0], [x1, z1]) + if (!occupied.has(`${xi}:${zi + 1}`)) addEdge([x1, z1], [x0, z1]) + if (!occupied.has(`${xi - 1}:${zi}`)) addEdge([x0, z1], [x0, z0]) + } + } + + const polygons: Point2D[][] = [] + while (edgeMap.size > 0) { + const firstEntry = edgeMap.entries().next().value as [string, Point2D] | undefined + if (!firstEntry) break + const [startKey] = firstEntry + const startParts = startKey.split(',').map(Number) + const sx = startParts[0] + const sz = startParts[1] + if (sx === undefined || sz === undefined) { + edgeMap.delete(startKey) + continue + } + const start: Point2D = [sx, sz] + const polygon: Point2D[] = [start] + let current = start + + while (true) { + const currentKey = `${current[0]},${current[1]}` + const next = edgeMap.get(currentKey) + if (!next) break + edgeMap.delete(currentKey) + if (pointsEqual(next, start)) { + break + } + polygon.push(next) + current = next + } + + if (polygon.length >= 3) { + polygons.push(polygonArea(polygon) < 0 ? [...polygon].reverse() : polygon) + } + } + + return polygons +} + +function getCurvedOpeningPolygon(stair: StairNode, offset: number = 0): Point2D[] { + const width = Math.max(stair.width ?? 1, 0.4) + const innerRadius = Math.max(0.01, (stair.innerRadius ?? 0.9) - offset) + const outerRadius = (stair.innerRadius ?? 0.9) + width + offset + const totalSweep = stair.sweepAngle ?? Math.PI / 2 + const baseOpeningSweep = + Math.abs(totalSweep) * + Math.max(CURVED_STAIR_SLAB_OPENING_RATIO, 1 / Math.max(stair.stepCount ?? 1, 1)) + const angleOffset = offset / Math.max(innerRadius, 0.1) + const openingSweep = + Math.sign(totalSweep || 1) * Math.min(Math.abs(totalSweep), baseOpeningSweep + angleOffset * 2) + + const startAngle = totalSweep / 2 - openingSweep + const endAngle = totalSweep / 2 + const segmentCount = Math.max( + 10, + Math.min( + 32, + Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5), + ), + ) + const outerPoints: Point2D[] = [] + const innerPoints: Point2D[] = [] + + for (let index = 0; index <= segmentCount; index++) { + const t = index / segmentCount + const angle = startAngle + (endAngle - startAngle) * t + outerPoints.push( + toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius), + ) + } + + for (let index = segmentCount; index >= 0; index--) { + const t = index / segmentCount + const angle = startAngle + (endAngle - startAngle) * t + innerPoints.push( + toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius), + ) + } + + return [...outerPoints, ...innerPoints] +} + +function getSpiralOpeningPolygon(stair: StairNode, offset: number = 0): Point2D[] { + const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) + offset + const segmentCount = 48 + + return Array.from({ length: segmentCount }).map((_, index) => { + const angle = (index / segmentCount) * Math.PI * 2 + return toWorldPlanPoint(stair, Math.cos(angle) * radius, Math.sin(angle) * radius) + }) +} + +function getStraightOpeningPolygonsForSurface( + stair: StairNode, + nodes: Record, + targetElevation: number, + openingOffsetOverride?: number, +) { + const layouts = getStraightStairLayouts(stair, nodes) + if (layouts.length === 0) return [] + + const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) + const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) + const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) + const openingRects: AxisAlignedRect[] = [] + + for (let index = 0; index < layouts.length; index += 1) { + const layout = layouts[index] + if (!layout) continue + + const { segment, transform } = layout + const segmentStartElevation = transform.position[1] + const segmentTopElevation = layout.topElevation + + if (segment.segmentType === 'stair') { + if (Math.abs(targetElevation - segmentTopElevation) <= targetThreshold) { + const openingDepth = getStraightFlightOpeningDepth(stair, segment) + const flightRect = getAxisAlignedRectFromPolygon( + getStraightSegmentLocalSlicePolygon( + layout, + Math.max(0, segment.length - openingDepth), + segment.length, + ), + ) + if (flightRect) openingRects.push(expandRect(flightRect, openingOffset)) + } + continue + } + + if (Math.abs(targetElevation - segmentStartElevation) > targetThreshold) { + continue + } + + const landingRects: AxisAlignedRect[] = [] + const landingRect = getAxisAlignedRectFromPolygon( + getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length), + ) + if (landingRect) landingRects.push(expandRect(landingRect, openingOffset)) + const previous = layouts[index - 1] + if (previous?.segment.segmentType === 'stair') { + const previousTopElevation = previous.topElevation + if (Math.abs(targetElevation - previousTopElevation) <= targetThreshold) { + const previousDepth = getStraightFlightOpeningDepth(stair, previous.segment) + const previousRect = getAxisAlignedRectFromPolygon( + getStraightSegmentLocalSlicePolygon( + previous, + Math.max(0, previous.segment.length - previousDepth), + previous.segment.length, + ), + ) + if (previousRect) landingRects.push(expandRect(previousRect, openingOffset)) + } + } + + openingRects.push(...landingRects) + } + + if (openingRects.length > 0) { + const unionPolygons = buildUnionPolygonsFromRects(openingRects).map((polygon) => + polygon.map(([x, z]) => toWorldPlanPoint(stair, x, z)), + ) + if (unionPolygons.length > 0) { + return unionPolygons + } + } + + let fallbackLayout = layouts[layouts.length - 1] + for (let index = layouts.length - 1; index >= 0; index -= 1) { + const layout = layouts[index] + if (layout?.segment.segmentType === 'stair') { + fallbackLayout = layout + break + } + } + return fallbackLayout ? [getStraightSegmentFootprintPolygon(stair, fallbackLayout)] : [] +} + +function getStairOpeningPolygons( + stair: StairNode, + nodes: Record, + targetElevation?: number, + openingOffsetOverride?: number, +) { + if ((stair.slabOpeningMode ?? 'none') !== 'destination') { + return [] + } + + const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) + + if (stair.stairType === 'curved') { + return [ + getCurvedOpeningPolygon(stair, Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)), + ] + } + + if (stair.stairType === 'spiral') { + const offset = Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0) + return [getSpiralOpeningPolygon(stair, offset)] + } + + if (typeof targetElevation === 'number') { + return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation, openingOffset) + } + + return getStraightOpeningPolygonsForSurface( + stair, + nodes, + Math.max(...getStraightStairLayouts(stair, nodes).map((layout) => layout.topElevation), 0), + openingOffset, + ) +} + +function getApplicableStairOpeningPolygons( + stair: StairNode, + nodes: Record, + targetElevation: number, + surfacePolygon: Point2D[], +) { + const configuredOffset = Math.max(stair.openingOffset ?? 0, 0) + const polygons = getStairOpeningPolygons(stair, nodes, targetElevation, configuredOffset) + const overlappingPolygons = polygons.filter((polygon) => polygonsOverlap(surfacePolygon, polygon)) + + if (overlappingPolygons.length === polygons.length || configuredOffset <= 1e-6) { + return overlappingPolygons + } + + const fallbackPolygons = getStairOpeningPolygons(stair, nodes, targetElevation, 0) + const overlappingFallbackPolygons = fallbackPolygons.filter((polygon) => + polygonsOverlap(surfacePolygon, polygon), + ) + + return overlappingFallbackPolygons.length === fallbackPolygons.length + ? overlappingFallbackPolygons + : overlappingPolygons +} + +function getTargetSlabElevationForStair( + stair: StairNode, + slab: SlabNode, + slabLevelId: string, + nodes: Record, +) { + const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) + const fromLevel = getLevelNumber(fromLevelId, nodes) + const slabLevel = getLevelNumber(slabLevelId, nodes) + + if (fromLevel === undefined || slabLevel === undefined) { + return slab.elevation ?? 0.05 + } + + return ( + (slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT + + (slab.elevation ?? 0.05) - + (stair.position[1] ?? 0) + ) +} + +function getTargetCeilingElevationForStair( + stair: StairNode, + ceiling: CeilingNode, + ceilingLevelId: string, + nodes: Record, +) { + const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) + const fromLevel = getLevelNumber(fromLevelId, nodes) + const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) + + if (fromLevel === undefined || ceilingLevel === undefined) { + return ceiling.height ?? DEFAULT_WALL_HEIGHT + } + + return ( + (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - + (stair.position[1] ?? 0) + ) +} + +function shouldApplyStairToSlab( + stair: StairNode, + slabLevelId: string, + nodes: Record, +) { + const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) + const fromLevel = getLevelNumber(fromLevelId, nodes) + const toLevel = getLevelNumber(toLevelId, nodes) + const slabLevel = getLevelNumber(slabLevelId, nodes) + + if (!isInStairBuildingScope(stair, slabLevelId, nodes)) return false + + if (slabLevel === undefined) { + return toLevelId === slabLevelId + } + + if (fromLevel === undefined || toLevel === undefined) { + return toLevelId === slabLevelId + } + + const minLevel = Math.min(fromLevel, toLevel) + const maxLevel = Math.max(fromLevel, toLevel) + return slabLevel > minLevel && slabLevel <= maxLevel +} + +function shouldApplyStairToCeiling( + stair: StairNode, + ceilingLevelId: string, + nodes: Record, +) { + const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) + const fromLevel = getLevelNumber(fromLevelId, nodes) + const toLevel = getLevelNumber(toLevelId, nodes) + const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) + + if (!isInStairBuildingScope(stair, ceilingLevelId, nodes)) return false + + if (ceilingLevel === undefined) { + return fromLevelId === ceilingLevelId + } + + if (fromLevel === undefined || toLevel === undefined) { + return fromLevelId === ceilingLevelId + } + + const minLevel = Math.min(fromLevel, toLevel) + const maxLevel = Math.max(fromLevel, toLevel) + return ceilingLevel >= minLevel && ceilingLevel < maxLevel +} + +export function syncAutoStairOpenings(nodes: Record) { + const stairs = Object.values(nodes).filter( + (node): node is StairNode => node.type === 'stair' && node.visible !== false, + ) + const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab') + const ceilings = Object.values(nodes).filter( + (node): node is CeilingNode => node.type === 'ceiling', + ) + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + + for (const slab of slabs) { + const slabLevelId = resolveLevelId(slab, nodes) + const existingHoles = slab.holes ?? [] + const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) + + const stairHoles = stairs + .filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes)) + .flatMap((stair) => + getApplicableStairOpeningPolygons( + stair, + nodes, + getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes), + slab.polygon, + ).map((polygon) => ({ + polygon, + metadata: { + source: 'stair' as const, + stairId: stair.id, + }, + })), + ) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...preservedHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...preservedHoles.map((hole) => ({ ...hole.metadata })), + ...stairHoles.map((hole) => hole.metadata), + ] + + if ( + !(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata)) + ) { + updates.push({ + id: slab.id, + data: { + holes: nextHoles, + holeMetadata: nextMetadata, + }, + }) + } + } + + for (const ceiling of ceilings) { + const ceilingLevelId = resolveLevelId(ceiling, nodes) + const existingHoles = ceiling.holes ?? [] + const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) + + const stairHoles = stairs + .filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes)) + .flatMap((stair) => + getApplicableStairOpeningPolygons( + stair, + nodes, + getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes), + ceiling.polygon, + ).map((polygon) => ({ + polygon, + metadata: { + source: 'stair' as const, + stairId: stair.id, + }, + })), + ) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...preservedHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...preservedHoles.map((hole) => ({ ...hole.metadata })), + ...stairHoles.map((hole) => hole.metadata), + ] + + if ( + !(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata)) + ) { + updates.push({ + id: ceiling.id, + data: { + holes: nextHoles, + holeMetadata: nextMetadata, + }, + }) + } + } + + return updates +} diff --git a/services/pascal-editor/packages/core/src/systems/stair/stair-opening-system.tsx b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-system.tsx new file mode 100644 index 000000000..b78224cf9 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/stair/stair-opening-system.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useEffect, useRef } from 'react' +import type { AnyNode } from '../../schema' +import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' +import useLiveNodeOverrides from '../../store/use-live-node-overrides' +import useLiveTransforms from '../../store/use-live-transforms' +import useScene from '../../store/use-scene' +import { + createSurfaceOpeningPreviewController, + getNodesWithLiveStairOpeningInputs, + hasLiveStairOpeningInputs, +} from './stair-opening-preview' +import { syncAutoStairOpenings } from './stair-opening-sync' + +function isOpeningRelevantNode(node: AnyNode | undefined) { + return ( + node?.type === 'building' || + node?.type === 'ceiling' || + node?.type === 'level' || + node?.type === 'slab' || + node?.type === 'stair' || + node?.type === 'stair-segment' + ) +} + +function hasOpeningRelevantNodeChange( + nextNodes: Record, + prevNodes: Record, +) { + if (nextNodes === prevNodes) return false + + const ids = new Set([...Object.keys(nextNodes), ...Object.keys(prevNodes)]) + for (const id of ids) { + const nextNode = nextNodes[id] + const prevNode = prevNodes[id] + if (nextNode === prevNode) continue + if (isOpeningRelevantNode(nextNode) || isOpeningRelevantNode(prevNode)) return true + } + + return false +} + +export const StairOpeningSystem = () => { + const syncingAutoOpeningsRef = useRef(false) + const syncingPreviewOpeningsRef = useRef(false) + const previewControllerRef = useRef(createSurfaceOpeningPreviewController()) + + useEffect(() => { + const applyUpdates = (updates: ReturnType) => { + if (updates.length === 0) return + syncingAutoOpeningsRef.current = true + pauseSceneHistory(useScene) + try { + useScene.getState().updateNodes(updates) + } finally { + resumeSceneHistory(useScene) + } + queueMicrotask(() => { + syncingAutoOpeningsRef.current = false + }) + } + + const applyPreviewUpdates = (updates: ReturnType) => { + syncingPreviewOpeningsRef.current = true + previewControllerRef.current.apply(updates) + queueMicrotask(() => { + syncingPreviewOpeningsRef.current = false + }) + } + + const clearPreviewUpdates = () => { + if (previewControllerRef.current.previewSurfaceIds.size === 0) return + syncingPreviewOpeningsRef.current = true + previewControllerRef.current.clear() + queueMicrotask(() => { + syncingPreviewOpeningsRef.current = false + }) + } + + const refreshLivePreview = () => { + if (syncingPreviewOpeningsRef.current) return + + const nodes = useScene.getState().nodes + const liveTransforms = useLiveTransforms.getState().transforms + const liveOverrides = useLiveNodeOverrides.getState().overrides + const previewSurfaceIds = previewControllerRef.current.previewSurfaceIds + + if (!hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds)) { + clearPreviewUpdates() + return + } + + applyPreviewUpdates( + syncAutoStairOpenings( + getNodesWithLiveStairOpeningInputs( + nodes, + liveTransforms, + liveOverrides, + previewSurfaceIds, + ), + ), + ) + } + + applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + refreshLivePreview() + + const unsubscribeScene = useScene.subscribe((state, prevState) => { + if (syncingAutoOpeningsRef.current) return + if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + applyUpdates(syncAutoStairOpenings(state.nodes)) + refreshLivePreview() + }) + + const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { + refreshLivePreview() + }) + + const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => { + refreshLivePreview() + }) + + return () => { + unsubscribeScene() + unsubscribeLiveTransforms() + unsubscribeLiveOverrides() + previewControllerRef.current.clear() + } + }, []) + + return null +} diff --git a/services/pascal-editor/packages/core/src/systems/wall/wall-curve.ts b/services/pascal-editor/packages/core/src/systems/wall/wall-curve.ts new file mode 100644 index 000000000..743f14cac --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/wall/wall-curve.ts @@ -0,0 +1,233 @@ +import type { FenceNode, WallNode } from '../../schema' +import type { Point2D } from './wall-mitering' + +const CURVE_EPSILON = 1e-6 +const DEFAULT_SAMPLE_SEGMENTS = 24 + +type WallCurveLike = Pick + +type CurveFrame = { + point: Point2D + tangent: Point2D + normal: Point2D +} + +type WallSurfaceMiterOverrides = { + startLeft?: Point2D + startRight?: Point2D + endLeft?: Point2D + endRight?: Point2D +} + +function clamp01(value: number) { + return Math.max(0, Math.min(1, value)) +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t +} + +function distance(a: Point2D, b: Point2D) { + return Math.hypot(b.x - a.x, b.y - a.y) +} + +export function getWallStartPoint(wall: WallCurveLike): Point2D { + return { x: wall.start[0], y: wall.start[1] } +} + +export function getWallEndPoint(wall: WallCurveLike): Point2D { + return { x: wall.end[0], y: wall.end[1] } +} + +export function getWallChordLength(wall: WallCurveLike) { + return distance(getWallStartPoint(wall), getWallEndPoint(wall)) +} + +export function getMaxWallCurveOffset(wall: WallCurveLike) { + return getWallChordLength(wall) / 2 +} + +export function getWallStraightSnapOffset(wall: WallCurveLike) { + return Math.min(0.03, Math.max(0.005, getWallChordLength(wall) * 0.005)) +} + +function clampCurveOffset(wall: WallCurveLike, offset: number) { + const maxOffset = getMaxWallCurveOffset(wall) + if (!Number.isFinite(maxOffset) || maxOffset < CURVE_EPSILON) { + return 0 + } + + return Math.max(-maxOffset, Math.min(maxOffset, offset)) +} + +export function normalizeWallCurveOffset(wall: WallCurveLike, offset: number) { + const clamped = clampCurveOffset(wall, offset) + return Math.abs(clamped) <= getWallStraightSnapOffset(wall) ? 0 : clamped +} + +export function getClampedWallCurveOffset(wall: WallCurveLike) { + const value = wall.curveOffset ?? 0 + const normalized = normalizeWallCurveOffset(wall, value) + return Math.abs(normalized) > CURVE_EPSILON ? normalized : 0 +} + +export function isCurvedWall(wall: WallCurveLike) { + return Math.abs(getClampedWallCurveOffset(wall)) > CURVE_EPSILON +} + +export function getWallChordFrame(wall: WallCurveLike) { + const start = getWallStartPoint(wall) + const end = getWallEndPoint(wall) + const dx = end.x - start.x + const dy = end.y - start.y + const length = Math.hypot(dx, dy) + + if (length < CURVE_EPSILON) { + return { + start, + end, + midpoint: start, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + length: 0, + } + } + + return { + start, + end, + midpoint: { + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2, + }, + tangent: { x: dx / length, y: dy / length }, + normal: { x: -dy / length, y: dx / length }, + length, + } +} + +function getWallArcData(wall: WallCurveLike) { + const chord = getWallChordFrame(wall) + const sagitta = getClampedWallCurveOffset(wall) + + if (Math.abs(sagitta) <= CURVE_EPSILON || chord.length < CURVE_EPSILON) { + return null + } + + const absSagitta = Math.abs(sagitta) + const radius = (chord.length * chord.length) / (8 * absSagitta) + absSagitta / 2 + const centerOffset = radius - absSagitta + const direction = Math.sign(sagitta) || 1 + const center = { + x: chord.midpoint.x + chord.normal.x * centerOffset * direction, + y: chord.midpoint.y + chord.normal.y * centerOffset * direction, + } + const startAngle = Math.atan2(chord.start.y - center.y, chord.start.x - center.x) + const endAngle = Math.atan2(chord.end.y - center.y, chord.end.x - center.x) + + let delta = endAngle - startAngle + if (direction > 0) { + while (delta <= 0) delta += Math.PI * 2 + } else { + while (delta >= 0) delta -= Math.PI * 2 + } + + return { center, radius, startAngle, delta, direction } +} + +export function getWallCurveFrameAt(wall: WallCurveLike, t: number): CurveFrame { + const chord = getWallChordFrame(wall) + if (!isCurvedWall(wall) || chord.length < CURVE_EPSILON) { + return { + point: { + x: lerp(chord.start.x, chord.end.x, clamp01(t)), + y: lerp(chord.start.y, chord.end.y, clamp01(t)), + }, + tangent: chord.tangent, + normal: chord.normal, + } + } + + const arc = getWallArcData(wall) + if (!arc) { + return { + point: chord.midpoint, + tangent: chord.tangent, + normal: chord.normal, + } + } + + const angle = arc.startAngle + arc.delta * clamp01(t) + const point = { + x: arc.center.x + Math.cos(angle) * arc.radius, + y: arc.center.y + Math.sin(angle) * arc.radius, + } + const tangent = + arc.direction > 0 + ? { x: -Math.sin(angle), y: Math.cos(angle) } + : { x: Math.sin(angle), y: -Math.cos(angle) } + + return { + point, + tangent, + normal: { + x: -tangent.y, + y: tangent.x, + }, + } +} + +export function getWallMidpointHandlePoint(wall: WallCurveLike) { + return getWallCurveFrameAt(wall, 0.5).point +} + +export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { + const count = Math.max(1, segments) + return Array.from( + { length: count + 1 }, + (_, index) => getWallCurveFrameAt(wall, index / count).point, + ) +} + +export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { + const points = sampleWallCenterline(wall, segments) + let totalLength = 0 + + for (let index = 1; index < points.length; index += 1) { + totalLength += distance(points[index - 1]!, points[index]!) + } + + return totalLength +} + +export function getWallSurfacePolygon( + wall: Pick, + segments = DEFAULT_SAMPLE_SEGMENTS, + miterOverrides?: WallSurfaceMiterOverrides, +) { + const halfThickness = (wall.thickness ?? 0.1) / 2 + const count = Math.max(1, segments) + const left: Point2D[] = [] + const right: Point2D[] = [] + + for (let index = 0; index <= count; index += 1) { + const frame = getWallCurveFrameAt(wall, index / count) + left.push({ + x: frame.point.x + frame.normal.x * halfThickness, + y: frame.point.y + frame.normal.y * halfThickness, + }) + right.push({ + x: frame.point.x - frame.normal.x * halfThickness, + y: frame.point.y - frame.normal.y * halfThickness, + }) + } + + if (left.length > 0 && right.length > 0) { + left[0] = miterOverrides?.startLeft ?? left[0]! + right[0] = miterOverrides?.startRight ?? right[0]! + left[left.length - 1] = miterOverrides?.endLeft ?? left[left.length - 1]! + right[right.length - 1] = miterOverrides?.endRight ?? right[right.length - 1]! + } + + return [...right, ...left.reverse()] +} diff --git a/services/pascal-editor/packages/core/src/systems/wall/wall-footprint.ts b/services/pascal-editor/packages/core/src/systems/wall/wall-footprint.ts new file mode 100644 index 000000000..0313699be --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/wall/wall-footprint.ts @@ -0,0 +1,79 @@ +import type { WallNode } from '../../schema' +import { getWallSurfacePolygon, isCurvedWall } from './wall-curve' +import { + getWallMiterBoundaryPoints, + type Point2D, + pointToKey, + type WallMiterData, +} from './wall-mitering' + +export const DEFAULT_WALL_THICKNESS = 0.1 +export const DEFAULT_WALL_HEIGHT = 2.5 +const CURVED_WALL_SURFACE_SEGMENTS = 24 + +export function getWallThickness(wallNode: WallNode): number { + return wallNode.thickness ?? DEFAULT_WALL_THICKNESS +} + +export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] { + const { junctionData } = miterData + const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } + const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } + const thickness = getWallThickness(wallNode) + const halfT = thickness / 2 + const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y } + const L = Math.sqrt(v.x * v.x + v.y * v.y) + + if (L < 1e-9) { + return [] + } + const nUnit = { x: -v.y / L, y: v.x / L } + const keyStart = pointToKey(wallStart) + const keyEnd = pointToKey(wallEnd) + const startJunction = junctionData.get(keyStart)?.get(wallNode.id) + const endJunction = junctionData.get(keyEnd)?.get(wallNode.id) + + if (isCurvedWall(wallNode)) { + const boundaryPoints = getWallMiterBoundaryPoints(wallNode, miterData) + if (!boundaryPoints) { + return [] + } + + const { startLeft, startRight, endLeft, endRight } = boundaryPoints + + return getWallSurfacePolygon(wallNode, CURVED_WALL_SURFACE_SEGMENTS, { + endLeft, + endRight, + startLeft, + startRight, + }) + } + + const pStartLeft: Point2D = startJunction?.left || { + x: wallStart.x + nUnit.x * halfT, + y: wallStart.y + nUnit.y * halfT, + } + const pStartRight: Point2D = startJunction?.right || { + x: wallStart.x - nUnit.x * halfT, + y: wallStart.y - nUnit.y * halfT, + } + const pEndLeft: Point2D = endJunction?.right || { + x: wallEnd.x + nUnit.x * halfT, + y: wallEnd.y + nUnit.y * halfT, + } + const pEndRight: Point2D = endJunction?.left || { + x: wallEnd.x - nUnit.x * halfT, + y: wallEnd.y - nUnit.y * halfT, + } + + const polygon: Point2D[] = [pStartRight, pEndRight] + if (endJunction) { + polygon.push(wallEnd) + } + polygon.push(pEndLeft, pStartLeft) + if (startJunction) { + polygon.push(wallStart) + } + + return polygon +} diff --git a/services/pascal-editor/packages/core/src/systems/wall/wall-mitering.test.ts b/services/pascal-editor/packages/core/src/systems/wall/wall-mitering.test.ts new file mode 100644 index 000000000..349850ed0 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/wall/wall-mitering.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import type { WallNode } from '../../schema' +import { calculateLevelMiters, getWallMiterBoundaryPoints } from './wall-mitering' + +function wall(id: string, start: [number, number], end: [number, number]): WallNode { + return { + id, + type: 'wall', + object: 'node', + visible: true, + parentId: 'level_test', + children: [], + start, + end, + thickness: 0.1, + height: 2.5, + frontSide: 'interior', + backSide: 'exterior', + metadata: {}, + } as WallNode +} + +function maxBoundaryCoord(walls: WallNode[]): number { + const miter = calculateLevelMiters(walls) + let max = 0 + for (const w of walls) { + const bp = getWallMiterBoundaryPoints(w, miter) + expect(bp).not.toBeNull() + if (!bp) continue + for (const p of [bp.startLeft, bp.startRight, bp.endLeft, bp.endRight]) { + expect(Number.isFinite(p.x)).toBe(true) + expect(Number.isFinite(p.y)).toBe(true) + max = Math.max(max, Math.abs(p.x), Math.abs(p.y)) + } + } + return max +} + +describe('wall mitering miter limit', () => { + // Two 3 m walls sharing the origin, meeting at decreasing angles. Without a + // miter limit the joint point runs to infinity as the angle → 0 (∝ 1/sin θ), + // which is the "infinite wall" seen when a room-preset preview lands on top of + // an existing wall. The boundary must stay bounded near the wall length. + test.each([90, 30, 10, 5, 1, 0.1, 0.01])('stays bounded at a %s° junction', (deg) => { + const rad = (deg * Math.PI) / 180 + const walls = [ + wall('A', [0, 0], [3, 0]), + wall('B', [0, 0], [3 * Math.cos(rad), 3 * Math.sin(rad)]), + ] + // 3 m walls + a few cm of joint: anything past ~4 m is a runaway spike. + expect(maxBoundaryCoord(walls)).toBeLessThan(4) + }) + + test('still miters a normal 90° corner', () => { + const walls = [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [0, 3])] + const miter = calculateLevelMiters(walls) + const bpA = getWallMiterBoundaryPoints(walls[0]!, miter) + expect(bpA).not.toBeNull() + if (!bpA) throw new Error('expected miter boundary points') + // The shared corner is pulled to the mitred intersection, offset from the + // raw butt position (halfThickness 0.05) by the diagonal of the joint. + const startSideX = Math.min(bpA.startLeft.x, bpA.startRight.x) + expect(startSideX).toBeLessThan(-0.001) + expect(startSideX).toBeGreaterThan(-0.5) + }) +}) diff --git a/services/pascal-editor/packages/core/src/systems/wall/wall-mitering.ts b/services/pascal-editor/packages/core/src/systems/wall/wall-mitering.ts new file mode 100644 index 000000000..193f11be3 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/wall/wall-mitering.ts @@ -0,0 +1,449 @@ +import type { WallNode } from '../../schema' +import { getWallCurveFrameAt, isCurvedWall } from './wall-curve' + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface Point2D { + x: number + y: number +} + +export interface WallMiterBoundaryPoints { + startLeft: Point2D + startRight: Point2D + endLeft: Point2D + endRight: Point2D +} + +interface LineEquation { + a: number + b: number + c: number // ax + by + c = 0 +} + +// Map of wallId -> { left?: Point2D, right?: Point2D } for each junction +type WallIntersections = Map + +// Map of junctionKey -> WallIntersections +type JunctionData = Map + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +const TOLERANCE = 0.001 + +// Miter joints are line-line intersections, so the joint point sits a distance +// ≈ halfThickness / sin(θ) from the junction, where θ is the angle between the +// two walls. As θ → 0 (two walls nearly collinear — e.g. a room-preset preview +// dragged on top of an existing wall, or a freshly-drawn wall almost parallel +// to its neighbour) that distance runs away to infinity and the wall renders as +// an infinite spike. Cap the joint at this multiple of the wall half-thickness; +// beyond it we fall back to a square (butt) joint, exactly like the existing +// parallel-walls guard. 10× preserves every realistic corner (a 0.1 m wall keeps +// mitering down to ~11°) while bounding the pathological near-collinear case. +const MITER_LIMIT = 10 + +function pointToKey(p: Point2D, tolerance = TOLERANCE): string { + const snap = 1 / tolerance + return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}` +} + +function createLineFromPointAndVector(p: Point2D, v: Point2D): LineEquation { + const a = -v.y + const b = v.x + const c = -(a * p.x + b * p.y) + return { a, b, c } +} + +/** + * Checks if a point lies on a wall segment (not at its endpoints) + */ +function pointOnWallSegment(point: Point2D, wall: WallNode, tolerance = TOLERANCE): boolean { + const start: Point2D = { x: wall.start[0], y: wall.start[1] } + const end: Point2D = { x: wall.end[0], y: wall.end[1] } + + // Check if point is at endpoints (those are handled separately) + if (pointToKey(point, tolerance) === pointToKey(start, tolerance)) return false + if (pointToKey(point, tolerance) === pointToKey(end, tolerance)) return false + + // Vector from start to end + const v = { x: end.x - start.x, y: end.y - start.y } + const L = Math.sqrt(v.x * v.x + v.y * v.y) + if (L < 1e-9) return false + + // Vector from start to point + const w = { x: point.x - start.x, y: point.y - start.y } + + // Project point onto wall line (t is parametric position along segment) + const t = (v.x * w.x + v.y * w.y) / (L * L) + + // Check if projection is within segment (not at endpoints) + if (t < tolerance || t > 1 - tolerance) return false + + // Check distance from point to line + const projX = start.x + t * v.x + const projY = start.y + t * v.y + const dist = Math.sqrt((point.x - projX) ** 2 + (point.y - projY) ** 2) + + return dist < tolerance +} + +// ============================================================================ +// JUNCTION DETECTION (exactly like demo) +// ============================================================================ + +interface Junction { + meetingPoint: Point2D + connectedWalls: Array<{ wall: WallNode; endType: 'start' | 'end' | 'passthrough' }> +} + +function findJunctions(walls: WallNode[]): Map { + const junctions = new Map() + + // First pass: group walls by their endpoints + for (const wall of walls) { + const startPt: Point2D = { x: wall.start[0], y: wall.start[1] } + const endPt: Point2D = { x: wall.end[0], y: wall.end[1] } + + const keyStart = pointToKey(startPt) + const keyEnd = pointToKey(endPt) + + if (!junctions.has(keyStart)) { + junctions.set(keyStart, { meetingPoint: startPt, connectedWalls: [] }) + } + junctions.get(keyStart)?.connectedWalls.push({ wall, endType: 'start' }) + + if (!junctions.has(keyEnd)) { + junctions.set(keyEnd, { meetingPoint: endPt, connectedWalls: [] }) + } + junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' }) + } + + // Second pass: detect T-junctions (walls passing through junction points) + for (const [_key, junction] of junctions.entries()) { + for (const wall of walls) { + // Skip if wall already in this junction + if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id)) continue + + // Check if junction point lies on this wall's segment (not at endpoints) + if (pointOnWallSegment(junction.meetingPoint, wall)) { + junction.connectedWalls.push({ wall, endType: 'passthrough' }) + } + } + } + + // Filter to only junctions with 2+ walls + const actualJunctions = new Map() + for (const [key, junction] of junctions.entries()) { + if (junction.connectedWalls.length >= 2) { + actualJunctions.set(key, junction) + } + } + + return actualJunctions +} + +function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') { + if (endType === 'passthrough') { + return { + x: wall.end[0] - wall.start[0], + y: wall.end[1] - wall.start[1], + } + } + + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) + return endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y } + } + + return endType === 'start' + ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } +} + +function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') { + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) + return { + point: frame.point, + tangent: endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y }, + normal: frame.normal, + } + } + + const point = + endType === 'start' + ? { x: wall.start[0], y: wall.start[1] } + : { x: wall.end[0], y: wall.end[1] } + const vector = + endType === 'start' + ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } + const length = Math.hypot(vector.x, vector.y) + + if (length < 1e-9) { + return { + point, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + + return { + point, + tangent: { x: vector.x / length, y: vector.y / length }, + normal: { x: -vector.y / length, y: vector.x / length }, + } +} + +// ============================================================================ +// MITER CALCULATION (exactly like demo) +// ============================================================================ + +interface ProcessedWall { + wallId: string + angle: number + edgeA: LineEquation // Left edge + edgeB: LineEquation // Right edge + isPassthrough: boolean // True if wall passes through junction (T-junction) + halfThickness: number // Used to bound the miter joint against runaway spikes +} + +function calculateJunctionIntersections( + junction: Junction, + getThickness: (wall: WallNode) => number, +): WallIntersections { + const { meetingPoint, connectedWalls } = junction + const processedWalls: ProcessedWall[] = [] + + for (const { wall, endType } of connectedWalls) { + const halfT = getThickness(wall) / 2 + + if (endType === 'passthrough') { + // For passthrough walls (T-junctions), add both directions + // This allows walls meeting the middle of this wall to miter against it + const v1 = { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + const v2 = { x: -v1.x, y: -v1.y } + + for (const v of [v1, v2]) { + const L = Math.sqrt(v.x * v.x + v.y * v.y) + if (L < 1e-9) continue + + const nUnit = { x: -v.y / L, y: v.x / L } + const pA = { x: meetingPoint.x + nUnit.x * halfT, y: meetingPoint.y + nUnit.y * halfT } + const pB = { x: meetingPoint.x - nUnit.x * halfT, y: meetingPoint.y - nUnit.y * halfT } + + const edgeA = createLineFromPointAndVector(pA, v) + const edgeB = createLineFromPointAndVector(pB, v) + const angle = Math.atan2(v.y, v.x) + + processedWalls.push({ + wallId: wall.id, + angle, + edgeA, + edgeB, + isPassthrough: true, + halfThickness: halfT, + }) + } + } else { + // Normal wall endpoint (start or end) + const v = getWallDirectionFromJunction(wall, endType) + + const L = Math.sqrt(v.x * v.x + v.y * v.y) + if (L < 1e-9) continue + + const nUnit = { x: -v.y / L, y: v.x / L } + const pA = { x: meetingPoint.x + nUnit.x * halfT, y: meetingPoint.y + nUnit.y * halfT } + const pB = { x: meetingPoint.x - nUnit.x * halfT, y: meetingPoint.y - nUnit.y * halfT } + + const edgeA = createLineFromPointAndVector(pA, v) + const edgeB = createLineFromPointAndVector(pB, v) + const angle = Math.atan2(v.y, v.x) + + processedWalls.push({ + wallId: wall.id, + angle, + edgeA, + edgeB, + isPassthrough: false, + halfThickness: halfT, + }) + } + } + + // Sort by outgoing angle + processedWalls.sort((a, b) => a.angle - b.angle) + + const wallIntersections = new Map() + const n = processedWalls.length + + if (n < 2) return wallIntersections + + // Calculate intersections between adjacent walls (exactly like demo) + for (let i = 0; i < n; i++) { + const wall1 = processedWalls[i]! + const wall2 = processedWalls[(i + 1) % n]! + + // Intersect left edge of wall1 with right edge of wall2 + const det = wall1.edgeA.a * wall2.edgeB.b - wall2.edgeB.a * wall1.edgeA.b + + // If lines are parallel (det ≈ 0), skip this intersection - walls will use defaults + if (Math.abs(det) < 1e-9) { + continue + } + + const p = { + x: (wall1.edgeA.b * wall2.edgeB.c - wall2.edgeB.b * wall1.edgeA.c) / det, + y: (wall2.edgeB.a * wall1.edgeA.c - wall1.edgeA.a * wall2.edgeB.c) / det, + } + + // Miter limit: `det` only catches walls that are *exactly* parallel. Two + // walls meeting at a shallow angle have a small-but-nonzero `det`, so `p` + // lands far from the junction (∝ 1/sin θ) and the wall renders as an + // infinite spike. Reject any joint farther than MITER_LIMIT half-thicknesses + // from the meeting point — those walls fall back to a square joint. + const maxMiter = MITER_LIMIT * Math.max(wall1.halfThickness, wall2.halfThickness) + const dx = p.x - meetingPoint.x + const dy = p.y - meetingPoint.y + if ( + !(Number.isFinite(p.x) && Number.isFinite(p.y)) || + dx * dx + dy * dy > maxMiter * maxMiter + ) { + continue + } + + // Only assign intersection to non-passthrough walls + // Passthrough walls don't receive junction data (their geometry doesn't change) + if (!wall1.isPassthrough) { + if (!wallIntersections.has(wall1.wallId)) { + wallIntersections.set(wall1.wallId, {}) + } + wallIntersections.get(wall1.wallId)!.left = p + } + + if (!wall2.isPassthrough) { + if (!wallIntersections.has(wall2.wallId)) { + wallIntersections.set(wall2.wallId, {}) + } + wallIntersections.get(wall2.wallId)!.right = p + } + } + + return wallIntersections +} + +// ============================================================================ +// MAIN EXPORT +// ============================================================================ + +export interface WallMiterData { + // Junction data keyed by junction position key + junctionData: JunctionData + // All junctions for quick lookup + junctions: Map +} + +/** + * Calculates miter data for all walls on a level + */ +export function calculateLevelMiters(walls: WallNode[]): WallMiterData { + const getThickness = (wall: WallNode) => wall.thickness ?? 0.1 + const junctions = findJunctions(walls) + const junctionData: JunctionData = new Map() + + for (const [key, junction] of junctions.entries()) { + const wallIntersections = calculateJunctionIntersections(junction, getThickness) + junctionData.set(key, wallIntersections) + } + + return { junctionData, junctions } +} + +export function getWallMiterBoundaryPoints( + wall: WallNode, + miterData: WallMiterData, +): WallMiterBoundaryPoints | null { + const thickness = wall.thickness ?? 0.1 + const halfThickness = thickness / 2 + const startFrame = getWallBoundaryFrame(wall, 'start') + const endFrame = getWallBoundaryFrame(wall, 'end') + const startJunction = miterData.junctionData.get(pointToKey(startFrame.point))?.get(wall.id) + const endJunction = miterData.junctionData.get(pointToKey(endFrame.point))?.get(wall.id) + + return { + startLeft: startJunction?.left ?? { + x: startFrame.point.x + startFrame.normal.x * halfThickness, + y: startFrame.point.y + startFrame.normal.y * halfThickness, + }, + startRight: startJunction?.right ?? { + x: startFrame.point.x - startFrame.normal.x * halfThickness, + y: startFrame.point.y - startFrame.normal.y * halfThickness, + }, + endLeft: endJunction?.right ?? { + x: endFrame.point.x + endFrame.normal.x * halfThickness, + y: endFrame.point.y + endFrame.normal.y * halfThickness, + }, + endRight: endJunction?.left ?? { + x: endFrame.point.x - endFrame.normal.x * halfThickness, + y: endFrame.point.y - endFrame.normal.y * halfThickness, + }, + } +} + +/** + * Gets wall IDs that share junctions with the given walls + */ +export function getAdjacentWallIds(allWalls: WallNode[], dirtyWallIds: Set): Set { + const adjacent = new Set() + + for (const dirtyId of dirtyWallIds) { + const dirtyWall = allWalls.find((w) => w.id === dirtyId) + if (!dirtyWall) continue + + const dirtyStart: Point2D = { x: dirtyWall.start[0], y: dirtyWall.start[1] } + const dirtyEnd: Point2D = { x: dirtyWall.end[0], y: dirtyWall.end[1] } + + for (const wall of allWalls) { + if (wall.id === dirtyId) continue + + const wallStart: Point2D = { x: wall.start[0], y: wall.start[1] } + const wallEnd: Point2D = { x: wall.end[0], y: wall.end[1] } + + // Check corner connections (endpoints meeting) + const startKey = pointToKey(wallStart) + const endKey = pointToKey(wallEnd) + const dirtyStartKey = pointToKey(dirtyStart) + const dirtyEndKey = pointToKey(dirtyEnd) + + if ( + startKey === dirtyStartKey || + startKey === dirtyEndKey || + endKey === dirtyStartKey || + endKey === dirtyEndKey + ) { + adjacent.add(wall.id) + continue + } + + // Check T-junction connections (dirty wall endpoint on other wall's segment) + if (pointOnWallSegment(dirtyStart, wall) || pointOnWallSegment(dirtyEnd, wall)) { + adjacent.add(wall.id) + continue + } + + // Check reverse T-junction (other wall endpoint on dirty wall's segment) + if (pointOnWallSegment(wallStart, dirtyWall) || pointOnWallSegment(wallEnd, dirtyWall)) { + adjacent.add(wall.id) + } + } + } + + return adjacent +} + +// Re-export for backwards compatibility +export { pointToKey } diff --git a/services/pascal-editor/packages/core/src/systems/wall/wall-move.ts b/services/pascal-editor/packages/core/src/systems/wall/wall-move.ts new file mode 100644 index 000000000..0197de2e9 --- /dev/null +++ b/services/pascal-editor/packages/core/src/systems/wall/wall-move.ts @@ -0,0 +1,323 @@ +import type { WallNode } from '../../schema' + +const AXIS_EPSILON = 1e-6 + +export type WallPlanPoint = [number, number] +// Unit direction vector (x,z) to constrain move deltas along. +export type WallMoveAxis = [number, number] +export type WallMoveEndpoint = 'start' | 'end' + +export type WallMoveBridgePlan> = { + wall: TWall + originalPoint: WallPlanPoint + movedEndpoint: WallMoveEndpoint +} + +export type WallMoveLinkedWallTargetPlan> = { + wall: TWall + originalPoint: WallPlanPoint + targetPoint: WallPlanPoint +} + +export type WallMoveJunctionPlan> = { + linkedWallsToMove: TWall[] + linkedWallTargetPlans: Array> + bridgePlans: Array> + wallsToDelete: TWall[] +} + +export function getPerpendicularWallMoveAxis( + start: WallPlanPoint, + end: WallPlanPoint, +): WallMoveAxis | null { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + + if (length < AXIS_EPSILON) return null + + // Perpendicular (normal) direction for moving the wall "sideways". + // This matches the arrow handles shown in the editor. + return [-dz / length, dx / length] +} + +export function constrainWallMoveDeltaToAxis( + deltaX: number, + deltaZ: number, + axis: WallMoveAxis | null, +): WallPlanPoint { + if (axis) { + const projected = deltaX * axis[0] + deltaZ * axis[1] + return [axis[0] * projected, axis[1] * projected] + } + return [deltaX, deltaZ] +} + +function pointsEqual(a: WallPlanPoint, b: WallPlanPoint) { + return Math.abs(a[0] - b[0]) <= AXIS_EPSILON && Math.abs(a[1] - b[1]) <= AXIS_EPSILON +} + +function wallTouchesPoint(wall: Pick, point: WallPlanPoint) { + return pointsEqual(wall.start, point) || pointsEqual(wall.end, point) +} + +function otherWallEndpoint(wall: Pick, point: WallPlanPoint) { + return pointsEqual(wall.start, point) ? wall.end : wall.start +} + +type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary' +type RelatedWallEntry> = { + wall: TWall + relation: MoveWallRelation +} + +function wallLengthFromPoint(wall: Pick, point: WallPlanPoint) { + const freeEndpoint = otherWallEndpoint(wall, point) + return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1]) +} + +function getMoveWallRelation( + wall: Pick, + sharedPoint: WallPlanPoint, + nextPoint: WallPlanPoint, +): MoveWallRelation { + const moveX = nextPoint[0] - sharedPoint[0] + const moveZ = nextPoint[1] - sharedPoint[1] + const moveLength = Math.hypot(moveX, moveZ) + + if (moveLength < AXIS_EPSILON) return 'stationary' + + const freeEndpoint = otherWallEndpoint(wall, sharedPoint) + const wallX = freeEndpoint[0] - sharedPoint[0] + const wallZ = freeEndpoint[1] - sharedPoint[1] + const wallLength = Math.hypot(wallX, wallZ) + + if (wallLength < AXIS_EPSILON) return 'stationary' + + const normalizedCross = Math.abs(moveX * wallZ - moveZ * wallX) / (moveLength * wallLength) + if (normalizedCross > 1e-4) return 'off-axis' + + const normalizedDot = (moveX * wallX + moveZ * wallZ) / (moveLength * wallLength) + return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction' +} + +/** + * Apply a junction plan to a list of linked walls, producing the per-wall + * endpoint updates. Mirrors the 3D `MoveWallTool`'s drag-preview behavior + * so the 2D move handler can drive the same scene topology. + * + * `linkedWallTargetPlans` take precedence over `linkedWallsToMove` — when + * the planner emits a target plan for a same-direction-consumed wall the + * matchPoint/targetPoint pair encodes the pivot, not the original ↔ next + * endpoint mapping. + */ +export function getLinkedWallUpdates>( + linkedWalls: Array<{ + wall: TWall + matchPoint?: WallPlanPoint + targetPoint?: WallPlanPoint + }>, + originalStart: WallPlanPoint, + originalEnd: WallPlanPoint, + nextStart: WallPlanPoint, + nextEnd: WallPlanPoint, +): Array<{ id: TWall['id']; start: WallPlanPoint; end: WallPlanPoint }> { + return linkedWalls.map(({ wall, matchPoint, targetPoint }) => { + if (matchPoint && targetPoint) { + return { + id: wall.id, + start: pointsEqual(wall.start, matchPoint) ? targetPoint : wall.start, + end: pointsEqual(wall.end, matchPoint) ? targetPoint : wall.end, + } + } + + const targetStart = targetPoint ?? nextStart + const targetEnd = targetPoint ?? nextEnd + + return { + id: wall.id, + start: pointsEqual(wall.start, originalStart) + ? targetStart + : pointsEqual(wall.start, originalEnd) + ? targetEnd + : wall.start, + end: pointsEqual(wall.end, originalStart) + ? targetStart + : pointsEqual(wall.end, originalEnd) + ? targetEnd + : wall.end, + } + }) +} + +export function getPlannedLinkedWallUpdates>( + plan: WallMoveJunctionPlan, + originalStart: WallPlanPoint, + originalEnd: WallPlanPoint, + nextStart: WallPlanPoint, + nextEnd: WallPlanPoint, +): Array<{ id: TWall['id']; start: WallPlanPoint; end: WallPlanPoint }> { + const movePlans = new Map< + TWall['id'], + { wall: TWall; matchPoint?: WallPlanPoint; targetPoint?: WallPlanPoint } + >() + + for (const wall of plan.linkedWallsToMove) { + movePlans.set(wall.id, { wall }) + } + + for (const targetPlan of plan.linkedWallTargetPlans) { + movePlans.set(targetPlan.wall.id, { + wall: targetPlan.wall, + matchPoint: targetPlan.originalPoint, + targetPoint: targetPlan.targetPoint, + }) + } + + return getLinkedWallUpdates( + Array.from(movePlans.values()), + originalStart, + originalEnd, + nextStart, + nextEnd, + ) +} + +export function planWallMoveJunctions>( + linkedWalls: TWall[], + originalStart: WallPlanPoint, + originalEnd: WallPlanPoint, + nextStart: WallPlanPoint, + nextEnd: WallPlanPoint, +): WallMoveJunctionPlan { + const linkedWallsToMove = new Map() + const linkedWallTargetPlans = new Map>() + const bridgePlans = new Map>() + const wallsToDelete = new Map() + + const addStandardEndpointPlan = ( + endpoint: WallMoveEndpoint, + point: WallPlanPoint, + nextPoint: WallPlanPoint, + relatedWalls: Array>, + keySuffix = '', + useTargetPlans = false, + ) => { + const hasSideBranch = relatedWalls.some((entry) => entry.relation === 'off-axis') + const hasOppositeBridge = relatedWalls.some( + (entry) => entry.relation === 'opposite-direction' && hasSideBranch, + ) + + for (const { wall, relation } of relatedWalls) { + if ( + relation === 'stationary' || + relation === 'same-direction' || + (relation === 'opposite-direction' && !hasSideBranch) + ) { + if (useTargetPlans) { + linkedWallTargetPlans.set(wall.id, { + wall, + originalPoint: point, + targetPoint: nextPoint, + }) + } else { + linkedWallsToMove.set(wall.id, wall) + } + continue + } + + if (relation === 'off-axis' && hasOppositeBridge) { + continue + } + + bridgePlans.set(`${wall.id}:${endpoint}${keySuffix}`, { + wall, + originalPoint: point, + movedEndpoint: endpoint, + }) + } + } + + const addEndpointPlan = ( + endpoint: WallMoveEndpoint, + point: WallPlanPoint, + nextPoint: WallPlanPoint, + ) => { + const moveLength = Math.hypot(nextPoint[0] - point[0], nextPoint[1] - point[1]) + const linkedAtEndpoint = linkedWalls + .filter((wall) => wallTouchesPoint(wall, point)) + .map((wall) => ({ + wall, + relation: getMoveWallRelation(wall, point, nextPoint), + })) + const consumedSameDirectionWall = linkedAtEndpoint + .filter((entry) => entry.relation === 'same-direction') + .map((entry) => ({ + ...entry, + distance: wallLengthFromPoint(entry.wall, point), + })) + .filter((entry) => moveLength + AXIS_EPSILON >= entry.distance) + .sort((a, b) => a.distance - b.distance)[0] + + if (consumedSameDirectionWall) { + const pivotPoint = [ + ...otherWallEndpoint(consumedSameDirectionWall.wall, point), + ] as WallPlanPoint + const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction') + + wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall) + linkedWallTargetPlans.set(consumedSameDirectionWall.wall.id, { + wall: consumedSameDirectionWall.wall, + originalPoint: point, + targetPoint: pivotPoint, + }) + + if (bridgeSource) { + linkedWallTargetPlans.set(bridgeSource.wall.id, { + wall: bridgeSource.wall, + originalPoint: point, + targetPoint: pivotPoint, + }) + + bridgePlans.set(`${bridgeSource.wall.id}:${endpoint}:through`, { + wall: bridgeSource.wall, + originalPoint: pivotPoint, + movedEndpoint: endpoint, + }) + return + } + + const linkedAtPivot = linkedWalls + .filter( + (wall) => + wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint), + ) + .map((wall) => ({ + wall, + relation: getMoveWallRelation(wall, pivotPoint, nextPoint), + })) + + addStandardEndpointPlan( + endpoint, + pivotPoint, + nextPoint, + linkedAtPivot, + ':through-pivot', + true, + ) + return + } + + addStandardEndpointPlan(endpoint, point, nextPoint, linkedAtEndpoint) + } + + addEndpointPlan('start', originalStart, nextStart) + addEndpointPlan('end', originalEnd, nextEnd) + + return { + linkedWallsToMove: Array.from(linkedWallsToMove.values()), + linkedWallTargetPlans: Array.from(linkedWallTargetPlans.values()), + bridgePlans: Array.from(bridgePlans.values()), + wallsToDelete: Array.from(wallsToDelete.values()), + } +} diff --git a/services/pascal-editor/packages/core/src/utils/clone-scene-graph.test.ts b/services/pascal-editor/packages/core/src/utils/clone-scene-graph.test.ts new file mode 100644 index 000000000..99456783f --- /dev/null +++ b/services/pascal-editor/packages/core/src/utils/clone-scene-graph.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import type { CollectionId } from '../schema/collections' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { forkSceneGraph, type SceneGraph } from './clone-scene-graph' + +function makeNode(id: string, type: string, extra: Record = {}): AnyNode { + return { + object: 'node', + id, + type, + parentId: null, + visible: true, + metadata: {}, + ...extra, + } as unknown as AnyNode +} + +function makeSceneGraph(): SceneGraph { + const site = makeNode('site_1', 'site', { children: ['level_1'] }) + const level = makeNode('level_1', 'level', { + parentId: 'site_1', + children: ['wall_1', 'scan_1', 'guide_1'], + }) + const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' }) + const scan = makeNode('scan_1', 'scan', { parentId: 'level_1', url: 'scan.glb' }) + const guide = makeNode('guide_1', 'guide', { parentId: 'level_1', url: 'guide.png' }) + + return { + nodes: { + ['site_1' as AnyNodeId]: site, + ['level_1' as AnyNodeId]: level, + ['wall_1' as AnyNodeId]: wall, + ['scan_1' as AnyNodeId]: scan, + ['guide_1' as AnyNodeId]: guide, + }, + rootNodeIds: ['site_1' as AnyNodeId], + collections: { + ['collection_1' as CollectionId]: { + id: 'collection_1' as CollectionId, + name: 'References', + nodeIds: ['scan_1', 'guide_1'] as AnyNodeId[], + }, + }, + } +} + +describe('forkSceneGraph', () => { + test('strips scan and guide nodes by default', () => { + const forked = forkSceneGraph(makeSceneGraph()) + const nodes = Object.values(forked.nodes) + + expect(nodes.some((node) => node.type === 'scan')).toBe(false) + expect(nodes.some((node) => node.type === 'guide')).toBe(false) + expect(nodes.some((node) => node.type === 'wall')).toBe(true) + expect(forked.collections).toEqual({}) + }) + + test('preserves scan and guide nodes when requested', () => { + const forked = forkSceneGraph(makeSceneGraph(), { preserveScans: true }) + const nodes = Object.values(forked.nodes) + + expect(nodes.some((node) => node.type === 'scan')).toBe(true) + expect(nodes.some((node) => node.type === 'guide')).toBe(true) + expect(nodes.map((node) => node.id)).not.toContain('scan_1') + expect(nodes.map((node) => node.id)).not.toContain('guide_1') + expect( + Object.values(forked.collections ?? {}).flatMap((collection) => collection.nodeIds), + ).toHaveLength(2) + }) +}) diff --git a/services/pascal-editor/packages/core/src/utils/clone-scene-graph.ts b/services/pascal-editor/packages/core/src/utils/clone-scene-graph.ts new file mode 100644 index 000000000..30aa6293a --- /dev/null +++ b/services/pascal-editor/packages/core/src/utils/clone-scene-graph.ts @@ -0,0 +1,321 @@ +import type { AnyNode, AnyNodeId } from '../schema' +import { generateId } from '../schema/base' +import type { Collection, CollectionId } from '../schema/collections' + +export type SceneGraph = { + nodes: Record + rootNodeIds: AnyNodeId[] + collections?: Record +} + +/** + * Extracts the type prefix from a node ID (e.g., "wall_abc123" -> "wall") + */ +function extractIdPrefix(id: string): string { + const underscoreIndex = id.indexOf('_') + return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex) +} + +/** + * Deep clones a scene graph with all node IDs regenerated while preserving + * parent-child relationships and other internal references. + * + * This is useful for: + * - Duplicating a project (host app creates a new project record, then loads the cloned scene) + * - Copying nodes between different projects + * - Multi-scene in-memory scenarios + */ +export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { + const { nodes, rootNodeIds, collections } = sceneGraph + + // Build ID mapping: old ID -> new ID + const idMap = new Map() + + // Pass 1: Generate new IDs for all nodes + for (const nodeId of Object.keys(nodes)) { + const prefix = extractIdPrefix(nodeId) + idMap.set(nodeId, generateId(prefix)) + } + + // Pass 2: Deep clone nodes with remapped references + const clonedNodes = {} as Record + + for (const [oldId, node] of Object.entries(nodes)) { + const newId = idMap.get(oldId)! as AnyNodeId + const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode + + // Remap parentId + if (clonedNode.parentId && typeof clonedNode.parentId === 'string') { + clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null + } + + // Remap children array (buildings, levels, walls, items, etc.) + // Children can be either string IDs or embedded node objects (with an `id` property). + // Normalize both forms to remapped string IDs. + if ('children' in clonedNode && Array.isArray(clonedNode.children)) { + ;(clonedNode as Record).children = (clonedNode.children as unknown[]) + .map((child) => { + if (typeof child === 'string') return idMap.get(child) + if ( + child && + typeof child === 'object' && + 'id' in child && + typeof (child as any).id === 'string' + ) { + return idMap.get((child as any).id) + } + return undefined + }) + .filter((id): id is string => id !== undefined) + } + + // Remap wallId (items/doors/windows attached to walls) + if ('wallId' in clonedNode && typeof clonedNode.wallId === 'string') { + ;(clonedNode as Record).wallId = idMap.get(clonedNode.wallId) as + | string + | undefined + } + + // Remap roofSegmentId (doors/windows/items hosted on roof wall faces) + if ('roofSegmentId' in clonedNode && typeof clonedNode.roofSegmentId === 'string') { + ;(clonedNode as Record).roofSegmentId = idMap.get( + clonedNode.roofSegmentId, + ) as string | undefined + } + + clonedNodes[newId] = clonedNode + } + + // Remap root node IDs + const clonedRootNodeIds = rootNodeIds + .map((id) => idMap.get(id)) + .filter((id): id is string => id !== undefined) as AnyNodeId[] + + // Clone and remap collections if present + let clonedCollections: Record | undefined + if (collections) { + clonedCollections = {} as Record + const collectionIdMap = new Map() + + for (const collectionId of Object.keys(collections)) { + collectionIdMap.set(collectionId, generateId('collection')) + } + + for (const [oldCollectionId, collection] of Object.entries(collections)) { + const newCollectionId = collectionIdMap.get(oldCollectionId)! + clonedCollections[newCollectionId] = { + ...collection, + id: newCollectionId, + nodeIds: collection.nodeIds + .map((nodeId) => idMap.get(nodeId)) + .filter((id): id is string => id !== undefined) as AnyNodeId[], + controlNodeId: collection.controlNodeId + ? (idMap.get(collection.controlNodeId) as AnyNodeId | undefined) + : undefined, + } + + // Update collectionIds on nodes that reference this collection + for (const oldNodeId of collection.nodeIds) { + const newNodeId = idMap.get(oldNodeId) + if (newNodeId && clonedNodes[newNodeId as AnyNodeId]) { + const node = clonedNodes[newNodeId as AnyNodeId] as Record + if ('collectionIds' in node && Array.isArray(node.collectionIds)) { + const oldColIds = node.collectionIds as string[] + node.collectionIds = oldColIds + .map((cid) => collectionIdMap.get(cid)) + .filter((id): id is CollectionId => id !== undefined) + } + } + } + } + } + + return { + nodes: clonedNodes, + rootNodeIds: clonedRootNodeIds, + ...(clonedCollections && { collections: clonedCollections }), + } +} + +/** + * Deep clones a level node and all its descendants with fresh IDs. + * All internal references (parentId, children, wallId) are remapped to the new IDs. + * The cloned level node's parentId is preserved (building ID) — not remapped. + * + * Unlike `cloneSceneGraph` (which operates on serialized data), this function works + * on live runtime nodes that may have non-serializable properties (Three.js objects, + * etc.). It uses JSON roundtrip to safely strip them. + * + * @returns clonedNodes - flat array of all cloned nodes (level + descendants) + * @returns newLevelId - the ID of the cloned level node + * @returns idMap - old ID → new ID mapping + */ +export function cloneLevelSubtree( + nodes: Record, + levelId: AnyNodeId, +): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map } { + const levelNode = nodes[levelId] + if (levelNode?.type !== 'level') { + throw new Error(`Node "${levelId}" is not a level`) + } + + // Recursively collect the level node + all descendants via children arrays + const subtreeIds = new Set() + const collect = (id: AnyNodeId) => { + if (subtreeIds.has(id)) return + const node = nodes[id] + if (!node) return + subtreeIds.add(id) + if ('children' in node && Array.isArray(node.children)) { + for (const childId of node.children as AnyNodeId[]) { + collect(childId) + } + } + } + collect(levelId) + + // Build ID mapping: old → new + const idMap = new Map() + for (const oldId of subtreeIds) { + const prefix = extractIdPrefix(oldId) + idMap.set(oldId, generateId(prefix)) + } + + const newLevelId = idMap.get(levelId)! as AnyNodeId + + // Clone each node with remapped references. + // Use JSON roundtrip instead of structuredClone because live runtime nodes may + // carry non-serializable properties (Three.js Object3D refs, functions, etc.) + // that structuredClone would throw on. + const clonedNodes: AnyNode[] = [] + for (const oldId of subtreeIds) { + const node = nodes[oldId] + if (!node) continue + + const newId = idMap.get(oldId)! as AnyNodeId + + // JSON roundtrip: safely strips functions, Object3D, circular refs, etc. + const cloned = JSON.parse(JSON.stringify(node)) as AnyNode + ;(cloned as Record).id = newId + + // Remap parentId — but only for descendants, not the level node itself + // (the level's parentId points to the building, which is outside the subtree) + if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') { + cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null + } + + // Remap children array + if ('children' in cloned && Array.isArray(cloned.children)) { + ;(cloned as Record).children = (cloned.children as unknown[]) + .map((child) => { + if (typeof child === 'string') return idMap.get(child) ?? child + if ( + child && + typeof child === 'object' && + 'id' in child && + typeof (child as any).id === 'string' + ) { + return idMap.get((child as any).id) ?? (child as any).id + } + return child + }) + .filter((id): id is string => typeof id === 'string') + } + + // Remap wallId (doors/windows attached to walls) + if ('wallId' in cloned && typeof cloned.wallId === 'string') { + ;(cloned as Record).wallId = idMap.get(cloned.wallId) ?? cloned.wallId + } + + // Remap roofSegmentId (doors/windows/items hosted on roof wall faces) + if ('roofSegmentId' in cloned && typeof cloned.roofSegmentId === 'string') { + ;(cloned as Record).roofSegmentId = + idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId + } + + clonedNodes.push(cloned) + } + + return { clonedNodes, newLevelId, idMap } +} + +export type ForkSceneGraphOptions = { + preserveScans?: boolean +} + +/** + * Forks a scene graph for use as a new project: clones with new IDs and, by + * default, strips scan and guide nodes since they contain user-uploaded imagery. + */ +export function forkSceneGraph( + sceneGraph: SceneGraph, + options: ForkSceneGraphOptions = {}, +): SceneGraph { + if (options.preserveScans) { + return cloneSceneGraph(sceneGraph) + } + + const { nodes, rootNodeIds, collections } = sceneGraph + + // First, identify scan and guide node IDs to exclude (user-uploaded imagery) + const excludedNodeIds = new Set() + for (const [nodeId, node] of Object.entries(nodes)) { + if (node.type === 'scan' || node.type === 'guide') { + excludedNodeIds.add(nodeId) + } + } + + // Build a filtered scene graph without scan nodes + const filteredNodes = {} as Record + for (const [nodeId, node] of Object.entries(nodes)) { + if (excludedNodeIds.has(nodeId)) continue + + const clonedNode = structuredClone(node) as AnyNode + + // Remove scan children from any parent that references them. + // Children can be string IDs or embedded node objects. + if ('children' in clonedNode && Array.isArray(clonedNode.children)) { + ;(clonedNode as Record).children = (clonedNode.children as unknown[]).filter( + (child) => { + const childId = + typeof child === 'string' + ? child + : child && typeof child === 'object' && 'id' in child + ? (child as any).id + : null + return childId ? !excludedNodeIds.has(childId) : true + }, + ) + } + + filteredNodes[nodeId as AnyNodeId] = clonedNode + } + + const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id)) + + // Filter collections to remove references to scan nodes + let filteredCollections: Record | undefined + if (collections) { + filteredCollections = {} as Record + for (const [collectionId, collection] of Object.entries(collections)) { + const filteredNodeIds = collection.nodeIds.filter((id) => !excludedNodeIds.has(id)) + if (filteredNodeIds.length > 0) { + filteredCollections[collectionId as CollectionId] = { + ...collection, + nodeIds: filteredNodeIds as AnyNodeId[], + controlNodeId: + collection.controlNodeId && excludedNodeIds.has(collection.controlNodeId) + ? undefined + : collection.controlNodeId, + } + } + } + } + + // Now clone the filtered graph with new IDs + return cloneSceneGraph({ + nodes: filteredNodes, + rootNodeIds: filteredRootNodeIds, + ...(filteredCollections && { collections: filteredCollections }), + }) +} diff --git a/services/pascal-editor/packages/core/src/utils/heal-scene-graph.test.ts b/services/pascal-editor/packages/core/src/utils/heal-scene-graph.test.ts new file mode 100644 index 000000000..699d9ce1b --- /dev/null +++ b/services/pascal-editor/packages/core/src/utils/heal-scene-graph.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test' +import { healSceneNodes } from './heal-scene-graph' + +describe('healSceneNodes', () => { + test('strips non-string (null) children entries', () => { + const { nodes, strippedChildRefs } = healSceneNodes({ + wall_a: { + id: 'wall_a', + type: 'wall', + start: [0, 0], + end: [1, 0], + children: [null, 'item_x'], + }, + item_x: { id: 'item_x', type: 'item' }, + }) + expect(strippedChildRefs).toBe(1) + expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x']) + }) + + test('drops childless zero-length walls and removes their parent reference', () => { + const { nodes, droppedWallIds } = healSceneNodes({ + level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] }, + wall_zero: { id: 'wall_zero', type: 'wall', start: [5, 5], end: [5, 5], children: [] }, + wall_real: { id: 'wall_real', type: 'wall', start: [0, 0], end: [3, 0], children: [] }, + }) + expect(droppedWallIds).toEqual(['wall_zero']) + expect('wall_zero' in nodes).toBe(false) + expect((nodes.level_0 as { children: string[] }).children).toEqual(['wall_real']) + }) + + test('keeps a zero-length wall that still hosts a door/window', () => { + const { nodes, droppedWallIds } = healSceneNodes({ + wall_z: { id: 'wall_z', type: 'wall', start: [1, 1], end: [1, 1], children: ['door_1'] }, + door_1: { id: 'door_1', type: 'door' }, + }) + expect(droppedWallIds).toEqual([]) + expect('wall_z' in nodes).toBe(true) + }) + + test('passes a clean scene through untouched', () => { + const input = { + wall_a: { id: 'wall_a', type: 'wall', start: [0, 0], end: [2, 0], children: ['door_1'] }, + door_1: { id: 'door_1', type: 'door' }, + } + const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes(input) + expect(droppedWallIds).toEqual([]) + expect(strippedChildRefs).toBe(0) + expect(nodes.wall_a).toBe(input.wall_a) + }) + + test('drops a stale child reference left behind by a reparent', () => { + // window's parentId says wall_b, but wall_a still lists it — the exact + // corruption that rendered a window twice (duplicate React keys in 2D). + const { nodes, strippedStaleChildRefs } = healSceneNodes({ + wall_a: { id: 'wall_a', type: 'wall', start: [0, 0], end: [2, 0], children: ['window_1'] }, + wall_b: { id: 'wall_b', type: 'wall', start: [2, 0], end: [4, 0], children: ['window_1'] }, + window_1: { id: 'window_1', type: 'window', parentId: 'wall_b' }, + }) + expect(strippedStaleChildRefs).toBe(1) + expect((nodes.wall_a as { children: string[] }).children).toEqual([]) + expect((nodes.wall_b as { children: string[] }).children).toEqual(['window_1']) + }) + + test('collapses same-array duplicate child references', () => { + const { nodes, strippedStaleChildRefs } = healSceneNodes({ + wall_a: { + id: 'wall_a', + type: 'wall', + start: [0, 0], + end: [2, 0], + children: ['door_1', 'door_1'], + }, + door_1: { id: 'door_1', type: 'door', parentId: 'wall_a' }, + }) + expect(strippedStaleChildRefs).toBe(1) + expect((nodes.wall_a as { children: string[] }).children).toEqual(['door_1']) + }) + + test('keeps children whose parentId matches or is absent', () => { + const input = { + wall_a: { + id: 'wall_a', + type: 'wall', + start: [0, 0], + end: [2, 0], + children: ['door_1', 'item_legacy'], + }, + door_1: { id: 'door_1', type: 'door', parentId: 'wall_a' }, + item_legacy: { id: 'item_legacy', type: 'item' }, + } + const { nodes, strippedStaleChildRefs } = healSceneNodes(input) + expect(strippedStaleChildRefs).toBe(0) + expect(nodes.wall_a).toBe(input.wall_a) + }) +}) diff --git a/services/pascal-editor/packages/core/src/utils/heal-scene-graph.ts b/services/pascal-editor/packages/core/src/utils/heal-scene-graph.ts new file mode 100644 index 000000000..80780b2b2 --- /dev/null +++ b/services/pascal-editor/packages/core/src/utils/heal-scene-graph.ts @@ -0,0 +1,111 @@ +// Repairs scene-graph corruption that pre-dates the source fixes, so existing +// saved scenes still load. Known kinds of damage: +// +// 1. A `children` array containing a non-string entry. The capture wall-merge +// re-attached a wall-hosted item without minting an id, so `undefined` was +// pushed into the wall's children — which serializes to `[null]`. The wall +// schema rejects `null` children, so the whole scene fails to load. +// 2. A zero-length wall (start === end). It renders nothing, but lingers as a +// junk node and is a foot-gun for snapping/mitering. +// 3. A child referenced by a parent it no longer belongs to: the child's +// `parentId` points at node B while node A's `children` still lists it +// (stale leftover from a reparent that didn't clean the old parent). The +// duplicate reference renders the child twice (duplicate React keys in the +// 2D plan, doubled hosted geometry in 3D). Same-array duplicates are +// collapsed too. +// +// All are also prevented at the source now; this is the load-time safety net +// for already-saved scenes. + +const ZERO_LENGTH_EPS = 1e-6 + +export interface HealSceneResult { + nodes: Record + /** Ids of zero-length walls that were dropped. */ + droppedWallIds: string[] + /** Count of non-string (e.g. null) entries removed from `children` arrays. */ + strippedChildRefs: number + /** + * Count of child references removed because the child's `parentId` points at + * a different node (stale reparent leftovers), plus same-array duplicates. + */ + strippedStaleChildRefs: number +} + +function isWallLike(node: unknown): node is { start: [number, number]; end: [number, number] } { + if (!node || typeof node !== 'object') return false + const n = node as Record + return ( + n.type === 'wall' && + Array.isArray(n.start) && + Array.isArray(n.end) && + typeof n.start[0] === 'number' && + typeof n.start[1] === 'number' && + typeof n.end[0] === 'number' && + typeof n.end[1] === 'number' + ) +} + +/** + * Returns a healed copy of a `nodes` map. Pure — does not mutate `input`. + * Nodes that need no repair are passed through by reference. + */ +export function healSceneNodes(input: Record): HealSceneResult { + const droppedWallIds: string[] = [] + + // Pass 1: drop childless zero-length walls. (Only childless ones — a wall + // carrying a door/window must keep its hosts, degenerate or not.) + const kept: Record = {} + for (const [id, node] of Object.entries(input)) { + if (isWallLike(node)) { + const children = (node as { children?: unknown }).children + const childless = !Array.isArray(children) || children.length === 0 + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + if (childless && Math.hypot(dx, dz) <= ZERO_LENGTH_EPS) { + droppedWallIds.push(id) + continue + } + } + kept[id] = node + } + + const dropped = new Set(droppedWallIds) + let strippedChildRefs = 0 + let strippedStaleChildRefs = 0 + + // Pass 2: clean `children` arrays — drop non-string entries (the `[null]` + // bug), references to walls we just removed, same-array duplicates, and + // stale references whose child's `parentId` names a different parent. + const nodes: Record = {} + for (const [id, node] of Object.entries(kept)) { + const children = (node as { children?: unknown })?.children + if (Array.isArray(children)) { + const seen = new Set() + const cleaned = children.filter((c): c is string => { + if (typeof c !== 'string' || dropped.has(c)) { + strippedChildRefs++ + return false + } + if (seen.has(c)) { + strippedStaleChildRefs++ + return false + } + seen.add(c) + const child = kept[c] as { parentId?: unknown } | undefined + if (child && typeof child.parentId === 'string' && child.parentId !== id) { + strippedStaleChildRefs++ + return false + } + return true + }) + if (cleaned.length !== children.length) { + nodes[id] = { ...(node as Record), children: cleaned } + continue + } + } + nodes[id] = node + } + + return { nodes, droppedWallIds, strippedChildRefs, strippedStaleChildRefs } +} diff --git a/services/pascal-editor/packages/core/src/utils/types.ts b/services/pascal-editor/packages/core/src/utils/types.ts new file mode 100644 index 000000000..6cfda9a5f --- /dev/null +++ b/services/pascal-editor/packages/core/src/utils/types.ts @@ -0,0 +1,7 @@ +/** + * Type guard to check if a value is a plain object (and not null or an array). + * Useful for narrowing down Zod's generic JSON types. + */ +export const isObject = (val: unknown): val is Record => { + return val !== null && typeof val === 'object' && !Array.isArray(val) +} diff --git a/services/pascal-editor/packages/core/src/validation/validate-build-json.ts b/services/pascal-editor/packages/core/src/validation/validate-build-json.ts new file mode 100644 index 000000000..128bfc34f --- /dev/null +++ b/services/pascal-editor/packages/core/src/validation/validate-build-json.ts @@ -0,0 +1,306 @@ +import { AnyNode, type AnyNodeType } from '../schema/types' +import { healSceneNodes } from '../utils/heal-scene-graph' + +export type ValidationSeverity = 'error' | 'warning' + +export type ValidationIssue = { + severity: ValidationSeverity + code: string + message: string + nodeId?: string +} + +export type BuildStats = { + total: number + byType: Partial> + unknownTypes: Record + floorAreaM2: number +} + +export type ParsedBuildJson = { + nodes: Record + rootNodeIds: string[] +} + +export type SchemaIssue = { + nodeId: string + nodeType: string + path: string + message: string +} + +export type ValidateBuildJsonResult = { + ok: boolean + parsed: ParsedBuildJson | null + stats: BuildStats + errors: ValidationIssue[] + warnings: ValidationIssue[] + schemaIssues: SchemaIssue[] + schemaIssueCount: number +} + +const KNOWN_TYPES = new Set( + AnyNode.options.map((o) => o.shape.type.parse(undefined) as string), +) + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function polygonAreaM2(points: ReadonlyArray): number { + if (points.length < 3) return 0 + let area = 0 + for (let i = 0; i < points.length; i++) { + const a = points[i] + const b = points[(i + 1) % points.length] + if (!a || !b) return 0 + area += a[0] * b[1] - b[0] * a[1] + } + return Math.abs(area) / 2 +} + +function isPointArray(value: unknown): value is ReadonlyArray { + if (!Array.isArray(value)) return false + return value.every( + (p) => + Array.isArray(p) && p.length === 2 && typeof p[0] === 'number' && typeof p[1] === 'number', + ) +} + +/** + * Pre-flight validator for `{ nodes, rootNodeIds }` build JSON loaded via + * Load Build (drag-drop, IFC converter output, hand-edited files). + * + * Reports issues without mutating; the scene store still owns migration + * and orphan cleanup at import time. Hard errors mean the file is + * structurally unusable and import should be blocked. + */ +export function validateBuildJson(input: unknown): ValidateBuildJsonResult { + const errors: ValidationIssue[] = [] + const warnings: ValidationIssue[] = [] + const schemaIssues: SchemaIssue[] = [] + const stats: BuildStats = { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 } + + if (!isPlainObject(input)) { + errors.push({ + severity: 'error', + code: 'not_an_object', + message: 'File is not a JSON object.', + }) + return { + ok: false, + parsed: null, + stats, + errors, + warnings, + schemaIssues, + schemaIssueCount: 0, + } + } + + const nodesRaw = input.nodes + const rootNodeIdsRaw = input.rootNodeIds + + if (!isPlainObject(nodesRaw)) { + errors.push({ + severity: 'error', + code: 'missing_nodes', + message: 'Missing or invalid "nodes" — expected an object of id → node.', + }) + } + if (!Array.isArray(rootNodeIdsRaw) || !rootNodeIdsRaw.every((id) => typeof id === 'string')) { + errors.push({ + severity: 'error', + code: 'missing_root_node_ids', + message: 'Missing or invalid "rootNodeIds" — expected an array of node IDs.', + }) + } + + if (errors.length > 0) { + return { + ok: false, + parsed: null, + stats, + errors, + warnings, + schemaIssues, + schemaIssueCount: 0, + } + } + + // Heal known pre-existing corruption (null children, zero-length walls) up + // front, so a scene saved before the source fixes still imports instead of + // hard-failing schema validation. `parsed` below carries the repaired nodes. + const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes( + nodesRaw as Record, + ) + const rootNodeIds = rootNodeIdsRaw as string[] + + if (strippedChildRefs > 0 || droppedWallIds.length > 0) { + warnings.push({ + severity: 'warning', + code: 'auto_repaired', + message: `Repaired on import: removed ${strippedChildRefs} invalid child reference${strippedChildRefs === 1 ? '' : 's'} and ${droppedWallIds.length} zero-length wall${droppedWallIds.length === 1 ? '' : 's'}.`, + }) + } + + if (rootNodeIds.length === 0) { + errors.push({ + severity: 'error', + code: 'empty_root_node_ids', + message: '"rootNodeIds" is empty — no entry point into the scene.', + }) + } + + let validRootCount = 0 + let mismatchedKeyCount = 0 + let schemaFailureCount = 0 + + for (const [key, value] of Object.entries(nodes)) { + if (!isPlainObject(value)) { + warnings.push({ + severity: 'warning', + code: 'node_not_object', + message: `Node "${key}" is not an object.`, + nodeId: key, + }) + continue + } + + stats.total += 1 + + const id = typeof value.id === 'string' ? value.id : null + const type = typeof value.type === 'string' ? value.type : null + const parentId = typeof value.parentId === 'string' ? value.parentId : null + + if (id && id !== key) { + mismatchedKeyCount += 1 + } + + if (!type) { + warnings.push({ + severity: 'warning', + code: 'missing_type', + message: `Node "${key}" has no "type" field.`, + nodeId: key, + }) + continue + } + + if (KNOWN_TYPES.has(type)) { + const t = type as AnyNodeType + stats.byType[t] = (stats.byType[t] ?? 0) + 1 + + const parseResult = AnyNode.safeParse(value) + if (!parseResult.success) { + schemaFailureCount += 1 + const issue = parseResult.error.issues[0] + schemaIssues.push({ + nodeId: key, + nodeType: type, + path: issue ? issue.path.join('.') : '', + message: issue ? issue.message : 'schema mismatch', + }) + } + + if (type === 'slab') { + const polygon = (value as { polygon?: unknown }).polygon + if (isPointArray(polygon)) { + let area = polygonAreaM2(polygon) + const holes = (value as { holes?: unknown }).holes + if (Array.isArray(holes)) { + for (const hole of holes) { + if (isPointArray(hole)) area -= polygonAreaM2(hole) + } + } + stats.floorAreaM2 += Math.max(0, area) + } + } + } else { + stats.unknownTypes[type] = (stats.unknownTypes[type] ?? 0) + 1 + } + + if (parentId && !(parentId in nodes)) { + warnings.push({ + severity: 'warning', + code: 'orphan_parent', + message: `Node "${key}" has parentId "${parentId}" which is not in the file (will be dropped on import).`, + nodeId: key, + }) + } + } + + if (mismatchedKeyCount > 0) { + warnings.push({ + severity: 'warning', + code: 'key_id_mismatch', + message: `${mismatchedKeyCount} node${mismatchedKeyCount === 1 ? '' : 's'} have a key that does not match their "id" field.`, + }) + } + + const unknownTypeNames = Object.keys(stats.unknownTypes) + if (unknownTypeNames.length > 0) { + const totalUnknown = unknownTypeNames.reduce((n, t) => n + stats.unknownTypes[t]!, 0) + warnings.push({ + severity: 'warning', + code: 'unknown_types', + message: `${totalUnknown} node${totalUnknown === 1 ? '' : 's'} use unknown type${unknownTypeNames.length === 1 ? '' : 's'}: ${unknownTypeNames.join(', ')}.`, + }) + } + + if (schemaFailureCount > 0) { + errors.push({ + severity: 'error', + code: 'schema_failure', + message: `${schemaFailureCount} node${schemaFailureCount === 1 ? '' : 's'} did not match the expected schema. See details below — these would cause the editor to crash on load.`, + }) + } + + for (const id of rootNodeIds) { + if (id in nodes) { + validRootCount += 1 + } else { + warnings.push({ + severity: 'warning', + code: 'orphan_root', + message: `Root node "${id}" is not in the file (will be ignored on import).`, + nodeId: id, + }) + } + } + + if (rootNodeIds.length > 0 && validRootCount === 0) { + errors.push({ + severity: 'error', + code: 'no_valid_roots', + message: 'None of the rootNodeIds point to a node in the file.', + }) + } + + const hasBuildingOrSite = (stats.byType.building ?? 0) > 0 || (stats.byType.site ?? 0) > 0 + if (!hasBuildingOrSite) { + warnings.push({ + severity: 'warning', + code: 'no_building', + message: 'No site or building node found.', + }) + } + if ((stats.byType.level ?? 0) === 0) { + warnings.push({ + severity: 'warning', + code: 'no_levels', + message: 'No level nodes found.', + }) + } + + const ok = errors.length === 0 + return { + ok, + parsed: ok ? { nodes, rootNodeIds } : null, + stats, + errors, + warnings, + schemaIssues, + schemaIssueCount: schemaFailureCount, + } +} diff --git a/services/pascal-editor/packages/core/tsconfig.json b/services/pascal-editor/packages/core/tsconfig.json new file mode 100644 index 000000000..98b1f97c9 --- /dev/null +++ b/services/pascal-editor/packages/core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@pascal/typescript-config/react-library.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "noEmit": false, + "composite": true, + "incremental": true, + "types": ["bun"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/services/pascal-editor/packages/editor/package.json b/services/pascal-editor/packages/editor/package.json new file mode 100644 index 000000000..2b864afa5 --- /dev/null +++ b/services/pascal-editor/packages/editor/package.json @@ -0,0 +1,69 @@ +{ + "name": "@pascal-app/editor", + "version": "0.9.1", + "description": "Pascal building editor component", + "type": "module", + "exports": { + ".": "./src/index.tsx", + "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx" + }, + "scripts": { + "check-types": "tsgo --noEmit" + }, + "peerDependencies": { + "@pascal-app/core": "^0.9.1", + "@pascal-app/viewer": "^0.9.1", + "@react-three/drei": "^10", + "@react-three/fiber": "^9", + "next": ">=15", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", + "three": "^0.184" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@iconify/react": "^6.0.2", + "@number-flow/react": "^0.6.0", + "@pascal-app/lingo": "^0.2.0", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@visual-json/react": "^0.4.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "howler": "^2.2.4", + "jspdf": "^4.2.1", + "lucide-react": "^1.7.0", + "mitt": "^3.0.1", + "motion": "^12.34.3", + "nanoid": "^5.1.6", + "svg2pdf.js": "^2.7.0", + "tailwind-merge": "^3.5.0", + "three-mesh-bvh": "~0.9.8", + "zod": "^4.3.6", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@pascal-app/core": "^0.9.1", + "@pascal-app/viewer": "^0.9.1", + "@pascal/typescript-config": "*", + "@types/bun": "^1.3.0", + "@types/howler": "^2.2.12", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@types/three": "^0.184.0", + "typescript": "6.0.3" + } +} diff --git a/services/pascal-editor/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/services/pascal-editor/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx new file mode 100644 index 000000000..b095ce43e --- /dev/null +++ b/services/pascal-editor/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -0,0 +1,104 @@ +'use client' + +import { memo, type MouseEvent as ReactMouseEvent } from 'react' +import useEditor from '../../store/use-editor' +import { + useEndpointReshape, + useIsCurveReshape, + useMovingNode, +} from '../../store/use-interaction-scope' +import { NodeActionMenu } from '../editor/node-action-menu' + +type SvgPoint = { + x: number + y: number +} + +export type FloorplanActionMenuHandler = (event: ReactMouseEvent) => void + +export type FloorplanActionMenuEntry = { + position: SvgPoint | null + onDelete: FloorplanActionMenuHandler + onMove?: FloorplanActionMenuHandler + onAddHole?: FloorplanActionMenuHandler + onCurve?: FloorplanActionMenuHandler + onDuplicate?: FloorplanActionMenuHandler +} + +type FloorplanActionMenuLayerProps = { + elevator: FloorplanActionMenuEntry + item: FloorplanActionMenuEntry + wall: FloorplanActionMenuEntry + fence: FloorplanActionMenuEntry + slab: FloorplanActionMenuEntry + ceiling: FloorplanActionMenuEntry + opening: FloorplanActionMenuEntry + spawn: FloorplanActionMenuEntry + stair: FloorplanActionMenuEntry + roof: FloorplanActionMenuEntry + offsetY?: number +} + +export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ + elevator, + item, + wall, + fence, + slab, + ceiling, + opening, + spawn, + stair, + roof, + offsetY = 10, +}: FloorplanActionMenuLayerProps) { + const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) + const movingNode = useMovingNode() + const endpointReshape = useEndpointReshape() + const isCurveReshape = useIsCurveReshape() + + if (!isFloorplanHovered || movingNode || endpointReshape || isCurveReshape) { + return null + } + + const entries: FloorplanActionMenuEntry[] = [ + elevator, + item, + wall, + fence, + slab, + ceiling, + opening, + spawn, + stair, + roof, + ] + + return ( + <> + {entries.map((entry, index) => + entry.position ? ( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
+ ) : null, + )} + + ) +}) diff --git a/services/pascal-editor/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/services/pascal-editor/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx new file mode 100644 index 000000000..b98d23940 --- /dev/null +++ b/services/pascal-editor/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx @@ -0,0 +1,142 @@ +'use client' + +import { useViewer } from '@pascal-app/viewer' +import { memo } from 'react' +import useAlignmentGuides from '../../store/use-alignment-guides' +import { formatMeasurement } from '../editor/measurement-pill' +import { useFloorplanRender } from './floorplan-render-context' + +/** + * Figma-style alignment guides for the 2D floor plan. + * + * Subscribes to the editor-local `useAlignmentGuides` store (separate + * from the core store the 3D layer reads). Guides come in + * building-local meters, so the layer is mounted INSIDE the rotated + * `` — the SVG transform that takes the rest + * of the floor-plan geometry from local → screen carries the guide + * lines too. Pill labels are counter-rotated by `sceneRotationDeg` + * (from `FloorplanRenderProvider`) so they stay upright even when the + * scene `` is rotated by building rotation. + * + * Each guide renders as a red line between the moving and matched + * candidate anchors with small `×` end-caps. A distance pill is drawn + * at the midpoint when the perpendicular gap is non-zero. + * + * Stroke widths and handle radii are scaled by `unitsPerPixel` so they + * stay a constant size on screen no matter the zoom. + */ +export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() { + const guides = useAlignmentGuides((s) => s.guides) + const unit = useViewer((s) => s.unit) + const ctx = useFloorplanRender() + + if (guides.length === 0) return null + + const upp = ctx?.unitsPerPixel ?? 0.01 + const sceneRot = ctx?.sceneRotationDeg ?? 0 + + // Pixel-budgeted sizes converted to world meters so visuals stay + // constant across zoom. Numbers picked to mirror Figma's snap chrome. + const stroke = 1 * upp + const xCapSize = 4 * upp + const pillFontSize = 11 * upp + const pillPadX = 5 * upp + const pillPadY = 3 * upp + const pillRadius = 3 * upp + const pillOffset = 8 * upp + + const color = '#ef4444' // tailwind red-500 — matches Figma's snap red + + return ( + + {guides.map((guide, i) => { + const { from, to, axis } = guide + const midX = (from.x + to.x) / 2 + const midZ = (from.z + to.z) / 2 + const distMeters = guide.distance + + // Pill placed offset perpendicular to the guide's axis so it + // doesn't sit on top of the line itself. For an X-axis guide + // (vertical line) we offset along Z; for a Z-axis guide we + // offset along X. + const pillX = axis === 'x' ? midX + pillOffset : midX + const pillZ = axis === 'z' ? midZ + pillOffset : midZ + const distLabel = formatMeasurement(distMeters, unit) + const charWidth = pillFontSize * 0.55 + const pillWidth = distLabel.length * charWidth + pillPadX * 2 + const pillHeight = pillFontSize + pillPadY * 2 + + return ( + + + + + {distMeters > 1e-4 && ( + // Counter-rotate the pill so it stays upright when the + // scene `` is rotated by building rotation. SVG's + // `transform` runs in the local coord system, so the + // rotation pivots around the pill's center. + + + + {distLabel} + + + )} + + ) + })} + + ) +}) + +function XCap({ + color, + size, + stroke, + x, + y, +}: { + color: string + size: number + stroke: number + x: number + y: number +}) { + return ( + + + + + ) +} diff --git a/services/pascal-editor/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx b/services/pascal-editor/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx new file mode 100644 index 000000000..80a672db6 --- /dev/null +++ b/services/pascal-editor/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx @@ -0,0 +1,162 @@ +'use client' + +import { Icon } from '@iconify/react' +import { memo, useMemo } from 'react' +import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor' +import { furnishTools } from '../ui/action-menu/furnish-tools' +import { tools as structureTools } from '../ui/action-menu/structure-tools' + +type SvgPoint = { + x: number + y: number +} + +type FloorplanCursorIndicator = + | { + kind: 'asset' + iconSrc: string + } + | { + kind: 'icon' + icon: string + } + +type FloorplanCursorIndicatorOverlayProps = { + cursorPosition: SvgPoint | null + floorplanSelectionTool: FloorplanSelectionTool + movingOpeningType: 'door' | 'window' | null + isPanning: boolean + cursorColor: string + indicatorLineHeight?: number + indicatorBadgeOffsetX?: number + indicatorBadgeOffsetY?: number +} + +export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({ + cursorPosition, + floorplanSelectionTool, + movingOpeningType, + isPanning, + cursorColor, + indicatorLineHeight = 18, + indicatorBadgeOffsetX = 14, + indicatorBadgeOffsetY = 14, +}: FloorplanCursorIndicatorOverlayProps) { + const mode = useEditor((state) => state.mode) + const tool = useEditor((state) => state.tool) + const structureLayer = useEditor((state) => state.structureLayer) + const catalogCategory = useEditor((state) => state.catalogCategory) + + const activeFloorplanToolConfig = useMemo(() => { + if (movingOpeningType) { + return structureTools.find((entry) => entry.id === movingOpeningType) ?? null + } + + if (mode !== 'build' || !tool) { + return null + } + + if (tool === 'item' && catalogCategory) { + return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null + } + + return structureTools.find((entry) => entry.id === tool) ?? null + }, [catalogCategory, mode, movingOpeningType, tool]) + + const indicator = useMemo(() => { + if (activeFloorplanToolConfig) { + return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc } + } + + if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') { + return { kind: 'icon', icon: 'mdi:select-drag' } + } + + if (mode === 'delete') { + return { kind: 'icon', icon: 'mdi:trash-can-outline' } + } + + if (mode === 'material-paint') { + return { kind: 'asset', iconSrc: '/icons/paint.webp' } + } + + return null + }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer]) + + const position = cursorPosition + + if (!(indicator && position) || isPanning) { + return null + } + + return ( +