Merge pull request #298 from pascalorg/chore/community-merge

Chore/community merge
This commit is contained in:
Wassim SAMAD
2026-05-09 20:37:12 -04:00
committed by GitHub
197 changed files with 6081 additions and 3968 deletions
+89 -31
View File
@@ -10,7 +10,9 @@ on:
options: options:
- core - core
- viewer - viewer
- both - editor
- mcp
- all
bump: bump:
description: "Version bump" description: "Version bump"
required: true required: true
@@ -51,56 +53,104 @@ jobs:
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump & publish core - name: Bump versions and sync inter-package references
if: inputs.package == 'core' || inputs.package == 'both' 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
echo "$MAJ.$MIN.$PAT"
}
for pkg in core viewer editor mcp; 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
UPPER=$(echo "$pkg" | tr '[:lower:]' '[:upper:]')
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; do
FILE=packages/$pkg/package.json
for dep in core viewer editor mcp; do
UPPER=$(echo "$dep" | tr '[:lower:]' '[:upper:]')
VAR="${UPPER}_VERSION"
VAL="${!VAR}"
[ -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
- name: Build & publish core
if: inputs.package == 'core' || inputs.package == 'all'
working-directory: packages/core working-directory: packages/core
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
BUMP=${{ inputs.bump }}
VERSION=$(jq -r '.version' package.json)
IFS='.' read -r MAJ MIN PAT <<< "$VERSION"
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
VERSION="$MAJ.$MIN.$PAT"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
echo "CORE_VERSION=$VERSION" >> $GITHUB_ENV
bun run build bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/core@$VERSION" echo "🏜️ Dry run — would publish @pascal-app/core@$CORE_VERSION"
npm publish --dry-run --access public npm publish --dry-run --access public
else else
npm publish --access public npm publish --access public
echo "📦 Published @pascal-app/core@$VERSION" echo "📦 Published @pascal-app/core@$CORE_VERSION"
fi fi
- name: Bump & publish viewer - name: Build & publish viewer
if: inputs.package == 'viewer' || inputs.package == 'both' if: inputs.package == 'viewer' || inputs.package == 'all'
working-directory: packages/viewer working-directory: packages/viewer
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
BUMP=${{ inputs.bump }}
VERSION=$(jq -r '.version' package.json)
IFS='.' read -r MAJ MIN PAT <<< "$VERSION"
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
VERSION="$MAJ.$MIN.$PAT"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
echo "VIEWER_VERSION=$VERSION" >> $GITHUB_ENV
bun run build bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/viewer@$VERSION" echo "🏜️ Dry run — would publish @pascal-app/viewer@$VIEWER_VERSION"
npm publish --dry-run --access public npm publish --dry-run --access public
else else
npm publish --access public npm publish --access public
echo "📦 Published @pascal-app/viewer@$VERSION" 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 fi
- name: Commit version bumps & tag - name: Commit version bumps & tag
@@ -118,6 +168,14 @@ jobs:
PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION" PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION"
TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION" TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION"
fi 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
git commit -m "release:${PKGS}" git commit -m "release:${PKGS}"
+1 -1
View File
@@ -173,7 +173,7 @@ function parseIfMatch(raw: string | null): number | undefined {
const inner = match ? match[1] : trimmed const inner = match ? match[1] : trimmed
if (!inner) return undefined if (!inner) return undefined
const n = Number(inner) const n = Number(inner)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return undefined if (!(Number.isFinite(n) && Number.isInteger(n)) || n < 0) return undefined
return n return n
} }
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "../../../styles/elevation.css";
@source "../../../packages/editor/src"; @source "../../../packages/editor/src";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
+26 -3
View File
@@ -1,13 +1,36 @@
'use client' 'use client'
import { Editor, type SidebarTab, ViewerToolbarLeft, ViewerToolbarRight } from '@pascal-app/editor' import {
Editor,
ItemsPanel,
type SidebarTab,
ViewerToolbarLeft,
ViewerToolbarRight,
} from '@pascal-app/editor'
import { Layers, Package, Settings } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ const SIDEBAR_TABS = [
{ {
id: 'site', id: 'site',
label: 'Scene', label: 'Scene',
component: () => null, // Built-in SitePanel handles this component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Layers className="h-5 w-5" />,
},
{
id: 'items',
label: 'Items',
component: ItemsPanel,
mobileDefaultSnap: 0.5,
mobileIcon: <Package className="h-5 w-5" />,
},
{
id: 'settings',
label: 'Settings',
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Settings className="h-5 w-5" />,
}, },
] ]
+6 -27
View File
@@ -1,8 +1,8 @@
/** /**
* Environment variable validation for the editor app. * Environment variable validation for the editor app.
* *
* This file validates that required environment variables are set at runtime. * This file validates environment variables used by the standalone editor app.
* Variables are defined in the root .env file. * Values are loaded from the repo root .env.local by package scripts.
* *
* @see https://env.t3.gg/docs/nextjs * @see https://env.t3.gg/docs/nextjs
*/ */
@@ -13,42 +13,21 @@ export const env = createEnv({
/** /**
* Server-side environment variables (not exposed to client) * Server-side environment variables (not exposed to client)
*/ */
server: { server: {},
// Database
POSTGRES_URL: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
// Auth
BETTER_AUTH_SECRET: z.string().min(1),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// Email
RESEND_API_KEY: z.string().optional(),
},
/** /**
* Client-side environment variables (exposed to browser via NEXT_PUBLIC_) * Client-side environment variables (exposed to browser via NEXT_PUBLIC_)
*/ */
client: { client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().min(1), NEXT_PUBLIC_ASSETS_CDN_URL: z.string().optional(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(),
}, },
/** /**
* Runtime values - pulls from process.env * Runtime values - pulls from process.env
*/ */
runtimeEnv: { runtimeEnv: {
// Server NEXT_PUBLIC_ASSETS_CDN_URL:
POSTGRES_URL: process.env.POSTGRES_URL, process.env.NEXT_PUBLIC_ASSETS_CDN_URL ?? process.env.NEXT_PUBLIC_EDITOR_ASSETS_CDN_URL,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
RESEND_API_KEY: process.env.RESEND_API_KEY,
// Client
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
}, },
/** /**
+3
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from 'next' import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
logging: {
browserToTerminal: true,
},
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
+5 -4
View File
@@ -4,8 +4,8 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "dotenv -e ./.env.local --override -- next dev --port 3002", "dev": "dotenv -e ../../.env.local -- next dev --port 3002",
"build": "dotenv -e ./.env.local --override -- next build", "build": "dotenv -e ../../.env.local -- next build",
"start": "next start", "start": "next start",
"lint": "biome lint", "lint": "biome lint",
"check-types": "next typegen && tsc --noEmit" "check-types": "next typegen && tsc --noEmit"
@@ -21,6 +21,7 @@
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"geist": "^1.7.0", "geist": "^1.7.0",
"lucide-react": "^1.7.0",
"next": "16.2.1", "next": "16.2.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
@@ -37,9 +38,9 @@
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"agentation": "^2.3.2", "agentation": "^2.3.2",
"react-grab": "^0.1.25", "react-grab": "^0.1.29",
"react-scan": "^0.5.3", "react-scan": "^0.5.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "5.9.3" "typescript": "6.0.2"
} }
} }
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M2 12h2M20 12h2"/>
<path d="M12 8v1M12 15v1M8 12h1M15 12h1"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "cd ../.. && npx -y bun@1.3.13 run build --filter=editor",
"installCommand": "cd ../.. && npx -y bun@1.3.13 install --frozen-lockfile",
"outputDirectory": ".next",
"cleanUrls": true,
"trailingSlash": false,
"bunVersion": "1.x"
}
+200 -241
View File
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -12,23 +12,40 @@
"check:fix": "biome check --write", "check:fix": "biome check --write",
"check-types": "turbo run check-types", "check-types": "turbo run check-types",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", "kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
"release": "gh workflow run release.yml -f package=both -f bump=patch", "release": "gh workflow run release.yml -f package=all -f bump=patch",
"release:viewer": "gh workflow run release.yml -f package=viewer -f bump=patch", "release:viewer": "gh workflow run release.yml -f package=viewer -f bump=patch",
"release:core": "gh workflow run release.yml -f package=core -f bump=patch", "release:core": "gh workflow run release.yml -f package=core -f bump=patch",
"release:minor": "gh workflow run release.yml -f package=both -f bump=minor", "release:editor": "gh workflow run release.yml -f package=editor -f bump=patch",
"release:major": "gh workflow run release.yml -f package=both -f bump=major" "release:mcp": "gh workflow run release.yml -f package=mcp -f bump=patch",
"release:minor": "gh workflow run release.yml -f package=all -f bump=minor",
"release:major": "gh workflow run release.yml -f package=all -f bump=major"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.6", "@biomejs/biome": "^2.4.6",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"turbo": "^2.8.15", "turbo": "^2.8.15",
"typescript": "5.9.3", "typescript": "6.0.2",
"ultracite": "^7.2.5" "ultracite": "^7.2.5"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"packageManager": "bun@1.3.0", "packageManager": "bun@1.3.0",
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/three": "0.184.0"
},
"optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0"
},
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*", "packages/*",
+3 -2
View File
@@ -73,9 +73,10 @@
}, },
"devDependencies": { "devDependencies": {
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"typescript": "5.9.3", "@types/three": "^0.184.0",
"@types/three": "^0.184.0" "typescript": "6.0.2"
}, },
"keywords": [ "keywords": [
"3d", "3d",
+10 -7
View File
@@ -28,12 +28,13 @@ export interface GridEvent {
/** World-space intersection point on the grid plane. */ /** World-space intersection point on the grid plane. */
position: [number, number, number] position: [number, number, number]
/** /**
* Building-local intersection point, relative to the currently selected building. * Building-local intersection point relative to the currently selected building.
* Equals `position` when no building is selected. * Equals `position` when no building is selected.
* Use this for placing or committing anything that lives inside a building * Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
* (walls, slabs, items, etc.).
*/ */
localPosition: [number, number, number] localPosition: [number, number, number]
faceIndex?: number
object: Object3D
nativeEvent: ThreeEvent<PointerEvent> nativeEvent: ThreeEvent<PointerEvent>
} }
@@ -66,7 +67,7 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode> export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode> export type DoorEvent = NodeEvent<DoorNode>
// Event suffixes, exported for use in hooks // Event suffixes - exported for use in hooks
export const eventSuffixes = [ export const eventSuffixes = [
'click', 'click',
'move', 'move',
@@ -99,7 +100,7 @@ export interface ThumbnailGenerateEvent {
/** /**
* When true, snap levels to their true positions before capturing (for a * When true, snap levels to their true positions before capturing (for a
* consistent auto-thumbnail angle) and defer the capture if the tab is * consistent auto-thumbnail angle) and defer the capture if the tab is
* hidden, the background auto-save path. Omit for user-driven captures * hidden the background auto-save path. Omit for user-driven captures
* that should fire immediately from the current camera pose. * that should fire immediately from the current camera pose.
*/ */
snapLevels?: boolean snapLevels?: boolean
@@ -107,8 +108,10 @@ export interface ThumbnailGenerateEvent {
export interface CameraControlFitSceneEvent { export interface CameraControlFitSceneEvent {
/** /**
* XZ-plane axis-aligned bounds for camera framing. Omitted values let the * XZ-plane axis-aligned bounds of the scene's geometry, computed from the
* listener choose its default framing pose. * scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The
* viewer's camera-controls listener frames the camera onto this box.
* Omitted values fall back to the camera's default pose.
*/ */
bounds?: { bounds?: {
min: [number, number] min: [number, number]
+5 -5
View File
@@ -6,10 +6,7 @@ export function insetPolygonFromCentroid(
return polygon.map(([x, z]) => [x, z] as [number, number]) return polygon.map(([x, z]) => [x, z] as [number, number])
} }
const centroid = polygon.reduce( const centroid = polygon.reduce((acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }), { x: 0, z: 0 })
(acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }),
{ x: 0, z: 0 },
)
centroid.x /= Math.max(polygon.length, 1) centroid.x /= Math.max(polygon.length, 1)
centroid.z /= Math.max(polygon.length, 1) centroid.z /= Math.max(polygon.length, 1)
@@ -70,7 +67,10 @@ function dedupePolygonPoints(
return deduped return deduped
} }
function simplifyPolyline(points: Array<[number, number]>, tolerance: number): Array<[number, number]> { function simplifyPolyline(
points: Array<[number, number]>,
tolerance: number,
): Array<[number, number]> {
if (points.length <= 2) { if (points.length <= 2) {
return points.map(([x, z]) => [x, z] as [number, number]) return points.map(([x, z]) => [x, z] as [number, number])
} }
+27 -16
View File
@@ -1,14 +1,20 @@
import { import {
getClampedWallCurveOffset, CeilingNode,
getWallCurveFrameAt, type CeilingNode as CeilingNodeType,
isCurvedWall, SlabNode,
} from '../systems/wall/wall-curve' type SlabNode as SlabNodeType,
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema' type WallNode,
} from '../schema'
import { import {
getSceneHistoryPauseDepth, getSceneHistoryPauseDepth,
pauseSceneHistory, pauseSceneHistory,
resumeSceneHistory, resumeSceneHistory,
} from '../store/history-control' } from '../store/history-control'
import {
getClampedWallCurveOffset,
getWallCurveFrameAt,
isCurvedWall,
} from '../systems/wall/wall-curve'
import { simplifyClosedPolygon } from './polygon-geometry' import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number } type Point2D = { x: number; y: number }
@@ -147,10 +153,10 @@ function polygonCentroid(points: Point2D[]) {
} }
function bboxOf(points: Point2D[]) { function bboxOf(points: Point2D[]) {
let minX = Infinity let minX = Number.POSITIVE_INFINITY
let minY = Infinity let minY = Number.POSITIVE_INFINITY
let maxX = -Infinity let maxX = Number.NEGATIVE_INFINITY
let maxY = -Infinity let maxY = Number.NEGATIVE_INFINITY
for (const point of points) { for (const point of points) {
minX = Math.min(minX, point.x) minX = Math.min(minX, point.x)
@@ -216,7 +222,13 @@ function sampleWallPointsForRoomDetection(
return [start, end] return [start, end]
} }
const subdivide = (t0: number, p0: Point2D, t1: number, p1: Point2D, depth: number): Point2D[] => { const subdivide = (
t0: number,
p0: Point2D,
t1: number,
p1: Point2D,
depth: number,
): Point2D[] => {
const midT = (t0 + t1) / 2 const midT = (t0 + t1) / 2
const midPoint = getWallCurveFrameAt(wall, midT).point const midPoint = getWallCurveFrameAt(wall, midT).point
const deviation = pointLineDistance(midPoint, p0, p1) const deviation = pointLineDistance(midPoint, p0, p1)
@@ -361,7 +373,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
const signedArea = polygonArea(polygon) const signedArea = polygonArea(polygon)
if (signedArea <= 0) continue if (signedArea <= 0) continue
if (signedArea < 0.5 || signedArea > 10000) continue if (signedArea < 0.5 || signedArea > 10_000) continue
const signature = polygonSignature(polygon) const signature = polygonSignature(polygon)
if (faces.some((face) => polygonSignature(face) === signature)) continue if (faces.some((face) => polygonSignature(face) === signature)) continue
@@ -442,10 +454,7 @@ function nextAutoRoomName(
return `Room ${maxIndex + 1} ${suffix}` return `Room ${maxIndex + 1} ${suffix}`
} }
function sameTuplePolygon( function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) {
current: Array<[number, number]>,
next: Array<[number, number]>,
) {
return ( return (
current.length === next.length && current.length === next.length &&
current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1]) current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1])
@@ -727,7 +736,9 @@ function syncAutoCeilingsForLevel(
const polygon = updatesById.get(ceiling.id) const polygon = updatesById.get(ceiling.id)
if (!polygon) return [] if (!polygon) return []
return sameTuplePolygon(ceiling.polygon, polygon) ? [] : [{ id: ceiling.id, data: { polygon } }] return sameTuplePolygon(ceiling.polygon, polygon)
? []
: [{ id: ceiling.id, data: { polygon } }]
}) })
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
+28
View File
@@ -1,5 +1,7 @@
import { import {
type MaterialPresetPayload, type MaterialPresetPayload,
type MaterialTarget,
MaterialTarget as MaterialTargetSchema,
} from './schema/material' } from './schema/material'
export type MaterialCatalogItem = { export type MaterialCatalogItem = {
@@ -12,6 +14,32 @@ export type MaterialCatalogItem = {
preset: MaterialPresetPayload preset: MaterialPresetPayload
} }
const WALL_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.wall]
const SLAB_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.slab]
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.wall,
MaterialTargetSchema.enum.slab,
]
const STAIR_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
]
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
...STAIR_TARGETS,
MaterialTargetSchema.enum.fence,
]
const ROOF_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.roof,
MaterialTargetSchema.enum['roof-segment'],
]
const CEILING_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.ceiling]
export const MATERIAL_CATEGORIES = [ export const MATERIAL_CATEGORIES = [
'wood', 'wood',
'wallpaper', 'wallpaper',
@@ -1,7 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
// The tsconfig in packages/core still emits this file; the @ts-expect-error
// keeps the build green while letting `bun test` pick it up normally.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { ALLOWED_ORIGINS_ENV, AssetUrl } from './asset-url' import { ALLOWED_ORIGINS_ENV, AssetUrl } from './asset-url'
+2 -2
View File
@@ -13,9 +13,9 @@ export const FenceNode = BaseNode.extend({
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
start: z.tuple([z.number(), z.number()]), start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]),
curveOffset: z.number().optional(),
height: z.number().default(1.8), height: z.number().default(1.8),
thickness: z.number().default(0.08), thickness: z.number().default(0.08),
curveOffset: z.number().optional(),
baseHeight: z.number().default(0.22), baseHeight: z.number().default(0.22),
postSpacing: z.number().default(2), postSpacing: z.number().default(2),
postSize: z.number().default(0.1), postSize: z.number().default(0.1),
@@ -29,8 +29,8 @@ export const FenceNode = BaseNode.extend({
dedent` dedent`
Fence node - used to represent a fence segment in the building/site level coordinate system Fence node - used to represent a fence segment in the building/site level coordinate system
- start/end: fence endpoints in level coordinate system - start/end: fence endpoints in level coordinate system
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- height/thickness: overall fence dimensions in meters - height/thickness: overall fence dimensions in meters
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration - groundClearance/edgeInset/baseStyle: fence support and inset configuration
- color/style: visual appearance options - color/style: visual appearance options
+12
View File
@@ -83,6 +83,18 @@ const assetSchema = z.object({
// Optional top-down 2D image shown inside the item's footprint on the // Optional top-down 2D image shown inside the item's footprint on the
// floor plan. When present, replaces the default diagonal-cross marker. // floor plan. When present, replaces the default diagonal-cross marker.
floorPlanUrl: z.string().optional(), 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, src: AssetUrl,
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
+9 -7
View File
@@ -1,25 +1,26 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material' import type { MaterialSchema as MaterialSchemaType } from '../material'
import { MaterialSchema } from '../material'
import { RoofSegmentNode } from './roof-segment' import { RoofSegmentNode } from './roof-segment'
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall' export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
export type RoofSurfaceMaterialSpec = { export type RoofSurfaceMaterialSpec = {
material?: MaterialSchema material?: MaterialSchemaType
materialPreset?: string materialPreset?: string
} }
export const RoofNode = BaseNode.extend({ export const RoofNode = BaseNode.extend({
id: objectId('roof'), id: objectId('roof'),
type: nodeType('roof'), type: nodeType('roof'),
material: MaterialSchemaSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
topMaterial: MaterialSchemaSchema.optional(), topMaterial: MaterialSchema.optional(),
topMaterialPreset: z.string().optional(), topMaterialPreset: z.string().optional(),
edgeMaterial: MaterialSchemaSchema.optional(), edgeMaterial: MaterialSchema.optional(),
edgeMaterialPreset: z.string().optional(), edgeMaterialPreset: z.string().optional(),
wallMaterial: MaterialSchemaSchema.optional(), wallMaterial: MaterialSchema.optional(),
wallMaterialPreset: z.string().optional(), wallMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
@@ -54,7 +55,8 @@ export function getEffectiveRoofSurfaceMaterial(
if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') { if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') {
return { return {
material: node.topMaterial, material: node.topMaterial,
materialPreset: typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined, materialPreset:
typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
} }
} }
} }
+19 -10
View File
@@ -1,7 +1,8 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material' import type { MaterialSchema as MaterialSchemaType } from '../material'
import { MaterialSchema } from '../material'
import { StairSegmentNode } from './stair-segment' import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
@@ -15,20 +16,20 @@ export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode> export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side' export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side'
export type StairSurfaceMaterialSpec = { export type StairSurfaceMaterialSpec = {
material?: MaterialSchema material?: MaterialSchemaType
materialPreset?: string materialPreset?: string
} }
export const StairNode = BaseNode.extend({ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
type: nodeType('stair'), type: nodeType('stair'),
material: MaterialSchemaSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
railingMaterial: MaterialSchemaSchema.optional(), railingMaterial: MaterialSchema.optional(),
railingMaterialPreset: z.string().optional(), railingMaterialPreset: z.string().optional(),
treadMaterial: MaterialSchemaSchema.optional(), treadMaterial: MaterialSchema.optional(),
treadMaterialPreset: z.string().optional(), treadMaterialPreset: z.string().optional(),
sideMaterial: MaterialSchemaSchema.optional(), sideMaterial: MaterialSchema.optional(),
sideMaterialPreset: z.string().optional(), sideMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
@@ -126,18 +127,26 @@ export function getEffectiveStairSurfaceMaterial(
const treadFallback = { const treadFallback = {
material: node.treadMaterial, material: node.treadMaterial,
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
} }
const sideFallback = { const sideFallback = {
material: node.sideMaterial, material: node.sideMaterial,
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
} }
if (role === 'tread' && (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)) { if (
role === 'tread' &&
(sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)
) {
return sideFallback return sideFallback
} }
if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) { if (
role === 'side' &&
(treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)
) {
return treadFallback return treadFallback
} }
@@ -122,7 +122,7 @@ function buildMergedWallAttachmentUpdates(
const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[] const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[]
for (const childId of wallChildren) { for (const childId of wallChildren) {
const child = nodes[childId] const child = nodes[childId]
if (!child || !('position' in child) || !Array.isArray(child.position)) { if (!(child && 'position' in child && Array.isArray(child.position))) {
continue continue
} }
@@ -190,10 +190,12 @@ function buildWallMergePlans(
}) })
const [primary, secondary] = sortedCandidates const [primary, secondary] = sortedCandidates
if ( if (
!primary || !(
!secondary || primary &&
!areWallStylesCompatible(primary, secondary) || secondary &&
!areWallsCollinearAcrossPoint(primary, secondary, junction) areWallStylesCompatible(primary, secondary) &&
areWallsCollinearAcrossPoint(primary, secondary, junction)
)
) { ) {
continue continue
} }
@@ -275,6 +277,7 @@ export const createNodesAction = (
ops.forEach(({ node, parentId }) => { ops.forEach(({ node, parentId }) => {
get().markDirty(node.id) get().markDirty(node.id)
if (parentId) get().markDirty(parentId) if (parentId) get().markDirty(parentId)
else if (node.parentId) get().markDirty(node.parentId as AnyNodeId)
}) })
} }
+12 -5
View File
@@ -111,7 +111,7 @@ function migrateWallSurfaceMaterials(node: Record<string, any>) {
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
} }
if (!hasInterior && !hasExterior) { if (!(hasInterior || hasExterior)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node return node
} }
@@ -174,7 +174,7 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
return legacyFinish return legacyFinish
} }
if (!hasRailing && !hasTread && !hasSide) { if (!(hasRailing || hasTread || hasSide)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node return node
} }
@@ -236,7 +236,7 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
} }
if (!hasTop && !hasEdge && !hasWall) { if (!(hasTop || hasEdge || hasWall)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node return node
} }
@@ -350,7 +350,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
} }
function getNodeChildIds(node: AnyNode): AnyNodeId[] { function getNodeChildIds(node: AnyNode): AnyNodeId[] {
if (!('children' in node) || !Array.isArray(node.children)) { if (!('children' in node && Array.isArray(node.children))) {
return [] return []
} }
@@ -511,6 +511,13 @@ const useScene: UseSceneStore = create<SceneState>()(
} }
} }
set({
nodes: cleanedNodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
})
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds) const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
if (normalizedRootNodeIds.length > 0) { if (normalizedRootNodeIds.length > 0) {
@@ -701,8 +708,8 @@ let prevFutureLength = 0
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
export function clearSceneHistory() { export function clearSceneHistory() {
useScene.temporal.getState().clear()
resetSceneHistoryPauseDepth() resetSceneHistoryPauseDepth()
useScene.temporal.getState().clear()
prevPastLength = 0 prevPastLength = 0
prevFutureLength = 0 prevFutureLength = 0
prevNodesSnapshot = null prevNodesSnapshot = null
@@ -1,5 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema' import type { AnyNode } from '../../schema'
import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
@@ -1,14 +1,12 @@
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { import type {
AnyNode, AnyNode,
AnyNodeId, AnyNodeId,
CeilingNode, CeilingNode,
LevelNode,
SlabNode, SlabNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
} from '../../schema' } from '../../schema'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
type Point2D = [number, number] type Point2D = [number, number]
@@ -36,10 +34,9 @@ type AxisAlignedRect = {
maxZ: number maxZ: number
} }
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.9 const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35 const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
const STAIR_SLAB_OPENING_TIGHTENING = 0 const STAIR_SLAB_OPENING_TIGHTENING = 0
const CURVED_STAIR_OPENING_STEP_PADDING = 3
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)) return Math.min(max, Math.max(min, value))
@@ -280,7 +277,7 @@ function polygonArea(points: Point2D[]) {
for (let index = 0; index < points.length; index += 1) { for (let index = 0; index < points.length; index += 1) {
const current = points[index] const current = points[index]
const next = points[(index + 1) % points.length] const next = points[(index + 1) % points.length]
if (!current || !next) continue if (!(current && next)) continue
area += current[0] * next[1] - next[0] * current[1] area += current[0] * next[1] - next[0] * current[1]
} }
return area / 2 return area / 2
@@ -426,39 +423,24 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
return polygons return polygons
} }
function getCurvedOpeningStepCount( function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
stair: StairNode, const width = Math.max(stair.width ?? 1, 0.4)
innerRadius: number, const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
outerRadius: number, const outerRadius = innerRadius + width
totalSweep: number, const totalSweep = stair.sweepAngle ?? Math.PI / 2
) { const openingSweep =
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) Math.sign(totalSweep || 1) *
const stepSweep = Math.abs(totalSweep) / stepCount
const midRadius = Math.max((innerRadius + outerRadius) * 0.5, 0.01)
const treadDepth = Math.max(stepSweep * midRadius, 0.2)
return Math.min(
stepCount,
Math.max( Math.max(
1, Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
Math.ceil(1.8 / treadDepth), Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO), )
), const startAngle = totalSweep / 2 - openingSweep
) const endAngle = totalSweep / 2
}
function buildArcOpeningPolygon(
stair: StairNode,
innerRadius: number,
outerRadius: number,
startAngle: number,
endAngle: number,
): Point2D[] {
const sweep = endAngle - startAngle
const segmentCount = Math.max( const segmentCount = Math.max(
10, 10,
Math.min( Math.min(
32, 32,
Math.ceil(Math.abs(sweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5), Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
), ),
) )
const outerPoints: Point2D[] = [] const outerPoints: Point2D[] = []
@@ -466,7 +448,7 @@ function buildArcOpeningPolygon(
for (let index = 0; index <= segmentCount; index++) { for (let index = 0; index <= segmentCount; index++) {
const t = index / segmentCount const t = index / segmentCount
const angle = startAngle + sweep * t const angle = startAngle + (endAngle - startAngle) * t
outerPoints.push( outerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius), toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
) )
@@ -474,8 +456,7 @@ function buildArcOpeningPolygon(
for (let index = segmentCount; index >= 0; index--) { for (let index = segmentCount; index >= 0; index--) {
const t = index / segmentCount const t = index / segmentCount
const angle = startAngle + sweep * t const angle = startAngle + (endAngle - startAngle) * t
innerPoints.push( innerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius), toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
) )
@@ -484,39 +465,6 @@ function buildArcOpeningPolygon(
return [...outerPoints, ...innerPoints] return [...outerPoints, ...innerPoints]
} }
function getCurvedOpeningPolygon(stair: StairNode, targetElevation?: number): Point2D[] {
const width = Math.max(stair.width ?? 1, 0.4)
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
const outerRadius = innerRadius + width
const totalSweep = stair.sweepAngle ?? Math.PI / 2
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
const stepSweep = totalSweep / stepCount
const targetThreshold = Math.max(stepHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const endAngle = totalSweep / 2
const fallbackStartStepIndex = Math.max(
0,
stepCount - getCurvedOpeningStepCount(stair, innerRadius, outerRadius, totalSweep),
)
let startStepIndex = fallbackStartStepIndex
if (typeof targetElevation === 'number') {
for (let index = 0; index < stepCount; index += 1) {
const stepTopElevation = stepHeight * (index + 1)
if (stepTopElevation >= targetElevation - targetThreshold) {
startStepIndex = Math.max(
0,
Math.min(fallbackStartStepIndex, index - CURVED_STAIR_OPENING_STEP_PADDING),
)
break
}
}
}
const startAngle = -totalSweep / 2 + stepSweep * startStepIndex
return buildArcOpeningPolygon(stair, innerRadius, outerRadius, startAngle, endAngle)
}
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] { function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
const segmentCount = 48 const segmentCount = 48
@@ -621,7 +569,7 @@ function getStairOpeningPolygons(
} }
if (stair.stairType === 'curved') { if (stair.stairType === 'curved') {
return [getCurvedOpeningPolygon(stair, targetElevation)] return [getCurvedOpeningPolygon(stair)]
} }
if (stair.stairType === 'spiral') { if (stair.stairType === 'spiral') {
@@ -775,8 +723,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
if ( if (
!polygonsEqual(existingHoles, nextHoles) || !(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
!metadataEqual(existingMetadata, nextMetadata)
) { ) {
updates.push({ updates.push({
id: slab.id, id: slab.id,
@@ -826,8 +773,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
if ( if (
!polygonsEqual(existingHoles, nextHoles) || !(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
!metadataEqual(existingMetadata, nextMetadata)
) { ) {
updates.push({ updates.push({
id: ceiling.id, id: ceiling.id,
+6 -3
View File
@@ -1,5 +1,5 @@
import type { Point2D } from './wall-mitering'
import type { FenceNode, WallNode } from '../../schema' import type { FenceNode, WallNode } from '../../schema'
import type { Point2D } from './wall-mitering'
const CURVE_EPSILON = 1e-6 const CURVE_EPSILON = 1e-6
const DEFAULT_SAMPLE_SEGMENTS = 24 const DEFAULT_SAMPLE_SEGMENTS = 24
@@ -115,7 +115,7 @@ function getWallArcData(wall: WallCurveLike) {
} }
const absSagitta = Math.abs(sagitta) const absSagitta = Math.abs(sagitta)
const radius = chord.length * chord.length / (8 * absSagitta) + absSagitta / 2 const radius = (chord.length * chord.length) / (8 * absSagitta) + absSagitta / 2
const centerOffset = radius - absSagitta const centerOffset = radius - absSagitta
const direction = Math.sign(sagitta) || 1 const direction = Math.sign(sagitta) || 1
const center = { const center = {
@@ -183,7 +183,10 @@ export function getWallMidpointHandlePoint(wall: WallCurveLike) {
export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
const count = Math.max(1, segments) const count = Math.max(1, segments)
return Array.from({ length: count + 1 }, (_, index) => getWallCurveFrameAt(wall, index / count).point) return Array.from(
{ length: count + 1 },
(_, index) => getWallCurveFrameAt(wall, index / count).point,
)
} }
export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
@@ -135,10 +135,7 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
return actualJunctions return actualJunctions
} }
function getWallDirectionFromJunction( function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') {
wall: WallNode,
endType: 'start' | 'end' | 'passthrough',
) {
if (endType === 'passthrough') { if (endType === 'passthrough') {
return { return {
x: wall.end[0] - wall.start[0], x: wall.end[0] - wall.start[0],
@@ -148,9 +145,7 @@ function getWallDirectionFromJunction(
if (isCurvedWall(wall)) { if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return endType === 'start' return endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y }
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y }
} }
return endType === 'start' return endType === 'start'
@@ -158,18 +153,12 @@ function getWallDirectionFromJunction(
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
} }
function getWallBoundaryFrame( function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
wall: WallNode,
endType: 'start' | 'end',
) {
if (isCurvedWall(wall)) { if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return { return {
point: frame.point, point: frame.point,
tangent: tangent: endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y },
endType === 'start'
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y },
normal: frame.normal, normal: frame.normal,
} }
} }
+2 -1
View File
@@ -5,7 +5,8 @@
"rootDir": "src", "rootDir": "src",
"noEmit": false, "noEmit": false,
"composite": true, "composite": true,
"incremental": true "incremental": true,
"types": ["bun"]
}, },
"include": ["src"], "include": ["src"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
+5 -5
View File
@@ -44,24 +44,24 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"howler": "^2.2.4", "howler": "^2.2.4",
"lucide-react": "^0.562.0", "lucide-react": "^1.7.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"motion": "^12.34.3", "motion": "^12.34.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11" "zustand": "^5.0.11",
"three-mesh-bvh": "~0.9.8"
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.7.0", "@pascal-app/core": "^0.7.0",
"@pascal-app/viewer": "^0.7.0", "@pascal-app/viewer": "^0.7.0",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/node": "^22.19.12",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"typescript": "5.9.3" "typescript": "6.0.2"
} }
} }
@@ -72,7 +72,12 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
)} )}
{polygonDraftPolygonPoints && ( {polygonDraftPolygonPoints && (
<polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" /> <polygon
fill={draftFill}
fillOpacity={0.2}
points={polygonDraftPolygonPoints}
stroke="none"
/>
)} )}
{polygonDraftPolylinePoints && ( {polygonDraftPolylinePoints && (
@@ -125,7 +125,12 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
return ( return (
<> <>
{measurements.map((measurement) => ( {measurements.map((measurement) => (
<g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}> <g
className={className}
key={measurement.id}
pointerEvents="none"
style={{ userSelect: 'none' }}
>
<FloorplanMeasurementLine <FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true} dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected} isSelected={measurement.isSelected}
@@ -396,8 +396,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<> <>
<polyline <polyline
fill="none" fill="none"
points={formatSvgPolygonPoints(arrow.polyline)}
pointerEvents="none" pointerEvents="none"
points={formatSvgPolygonPoints(arrow.polyline)}
stroke={straightAccent} stroke={straightAccent}
strokeWidth="1.15" strokeWidth="1.15"
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
@@ -411,8 +411,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
/> />
<polygon <polygon
fill={straightAccent} fill={straightAccent}
points={formatSvgPolygonPoints(arrow.head)}
pointerEvents="none" pointerEvents="none"
points={formatSvgPolygonPoints(arrow.head)}
/> />
</> </>
) : null} ) : null}
@@ -438,8 +438,6 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
} }
: undefined : undefined
} }
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
onPointerDown={ onPointerDown={
canFocusStairs && stairSelected canFocusStairs && stairSelected
? (event) => { ? (event) => {
@@ -449,6 +447,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
} }
: undefined : undefined
} }
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
pointerEvents={canSelectStairs ? undefined : 'none'} pointerEvents={canSelectStairs ? undefined : 'none'}
style={canSelectStairs ? { cursor } : undefined} style={canSelectStairs ? { cursor } : undefined}
> >
@@ -456,8 +456,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<polygon <polygon
fill="transparent" fill="transparent"
key={`${stair.id}:hit:${polygonIndex}`} key={`${stair.id}:hit:${polygonIndex}`}
points={formatSvgPolygonPoints(polygon)}
pointerEvents={canSelectStairs ? 'all' : 'none'} pointerEvents={canSelectStairs ? 'all' : 'none'}
points={formatSvgPolygonPoints(polygon)}
stroke="transparent" stroke="transparent"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={hitStrokeWidth} strokeWidth={hitStrokeWidth}
@@ -1,4 +1,5 @@
'use client' 'use client'
import { import {
type CameraControlEvent, type CameraControlEvent,
type CameraControlFitSceneEvent, type CameraControlFitSceneEvent,
@@ -6,7 +7,7 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
+4 -3
View File
@@ -205,6 +205,7 @@ export function EditorLayoutV2({
viewerContent, viewerContent,
overlays, overlays,
}: EditorLayoutV2Props) { }: EditorLayoutV2Props) {
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const isMobile = useIsMobile() const isMobile = useIsMobile()
if (isMobile) { if (isMobile) {
@@ -229,7 +230,7 @@ export function EditorLayoutV2({
{/* Main content: left column + right column */} {/* Main content: left column + right column */}
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
{sidebarTabs.length > 0 && ( {!isCaptureMode && sidebarTabs.length > 0 && (
<LeftColumn <LeftColumn
renderTabContent={renderTabContent} renderTabContent={renderTabContent}
sidebarOverlay={sidebarOverlay} sidebarOverlay={sidebarOverlay}
@@ -238,8 +239,8 @@ export function EditorLayoutV2({
)} )}
<RightColumn <RightColumn
overlays={overlays} overlays={overlays}
toolbarLeft={viewerToolbarLeft} toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
toolbarRight={viewerToolbarRight} toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
> >
{viewerContent} {viewerContent}
</RightColumn> </RightColumn>
@@ -455,14 +455,15 @@ export const FirstPersonControls = () => {
{controllerStart && ( {controllerStart && (
<KeyboardControls map={keyboardMap}> <KeyboardControls map={keyboardMap}>
<BVHEcctrl <BVHEcctrl
ref={controllerRef} acceleration={26}
key="first-person-controller" airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]} colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]} colliderMeshes={[world.mesh]}
collisionCheckIteration={3} collisionCheckIteration={3}
collisionPushBackDamping={0.1} collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001} collisionPushBackThreshold={0.001}
debug={false} debug={false}
deceleration={30}
delay={0} delay={0}
fallGravityFactor={4} fallGravityFactor={4}
floatCheckType="BOTH" floatCheckType="BOTH"
@@ -473,13 +474,12 @@ export const FirstPersonControls = () => {
floatSpringK={1200} floatSpringK={1200}
gravity={9.81} gravity={9.81}
jumpVel={6} jumpVel={6}
key="first-person-controller"
maxRunSpeed={5.5} maxRunSpeed={5.5}
maxSlope={1.2} maxSlope={1.2}
maxWalkSpeed={4} maxWalkSpeed={4}
position={controllerStart.position} position={controllerStart.position}
acceleration={26} ref={controllerRef}
airDragFactor={0.3}
deceleration={30}
/> />
</KeyboardControls> </KeyboardControls>
)} )}
@@ -551,12 +551,12 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
{isLocked && ( {isLocked && (
<div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2"> <div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl"> <div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} /> <ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
<div className="h-px w-full bg-border/30" /> <div className="h-px w-full bg-border/30" />
<InlineControlHint label="Jump" keyLabel="Space" /> <InlineControlHint keyLabel="Space" label="Jump" />
<InlineControlHint label="Sprint" keyLabel="Shift" /> <InlineControlHint keyLabel="Shift" label="Sprint" />
<InlineControlHint label="Interact" keyLabel="E / R" /> <InlineControlHint keyLabel="E / R" label="Interact" />
<InlineControlHint label="Close" keyLabel="T" /> <InlineControlHint keyLabel="T" label="Close" />
<div className="h-px w-full bg-border/30" /> <div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs"> <span className="text-center text-muted-foreground/60 text-xs">
Click to look around Click to look around
@@ -591,7 +591,7 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) { function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
return ( return (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em] uppercase"> <span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
{label} {label}
</span> </span>
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none"> <kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
@@ -1,7 +1,7 @@
import { import {
getGarageVisibleOpeningRatio,
type AnyNodeId, type AnyNodeId,
type DoorNode, type DoorNode,
getGarageVisibleOpeningRatio,
isOperationDoorType, isOperationDoorType,
sceneRegistry, sceneRegistry,
useInteractive, useInteractive,
@@ -97,11 +97,11 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
const node = useScene.getState().nodes[nodeId as AnyNodeId] const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (!node || node.type !== 'door') return false if (!node || node.type !== 'door') return false
if (node.openingKind === 'opening') return true
if (!node.segments.length) return true if (!node.segments.length) return true
return node.segments.every((segment) => segment.type === 'empty') if (node.openingKind === 'opening') return true
return node.segments.every((segment: { type: string }) => segment.type === 'empty')
} }
function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
@@ -273,9 +273,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
} }
const mergedGeometry = mergeGeometries(geometries, false) const mergedGeometry = mergeGeometries(geometries, false)
geometries.forEach((geometry) => { geometries.forEach((geometry) => geometry.dispose())
geometry.dispose()
})
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) { if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
mergedGeometry?.dispose() mergedGeometry?.dispose()
@@ -1,15 +1,8 @@
import '../../../three-types' import '../../../three-types'
import { TransformControls, useKeyboardControls } from '@react-three/drei' import { TransformControls, useKeyboardControls } from '@react-three/drei'
import { useFrame, useThree, type ThreeElements } from '@react-three/fiber' import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
import {
Suspense,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { forwardRef, Suspense, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { clamp } from 'three/src/math/MathUtils.js' import { clamp } from 'three/src/math/MathUtils.js'
@@ -156,16 +149,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const moveDirRef = useRef<THREE.ArrowHelper | null>(null) const moveDirRef = useRef<THREE.ArrowHelper | null>(null)
const elapsedRef = useRef(0) const elapsedRef = useRef(0)
function useIsInsideKeyboardControls() { const [, getKeys] = useKeyboardControls()
try {
return !!useKeyboardControls()
} catch {
return false
}
}
const isInsideKeyboardControls = useIsInsideKeyboardControls()
const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null]
const presetKeys = { const presetKeys = {
forward: false, forward: false,
backward: false, backward: false,
@@ -222,11 +206,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const scaledContactRadiusVec = useRef(new THREE.Vector3()) const scaledContactRadiusVec = useRef(new THREE.Vector3())
const deltaDist = useRef(new THREE.Vector3()) const deltaDist = useRef(new THREE.Vector3())
const currSlopeAngle = useRef(0) const currSlopeAngle = useRef(0)
const localMinDistance = useRef(Infinity) const localMinDistance = useRef(Number.POSITIVE_INFINITY)
const localClosestPoint = useRef(new THREE.Vector3()) const localClosestPoint = useRef(new THREE.Vector3())
const localHitNormal = useRef(new THREE.Vector3()) const localHitNormal = useRef(new THREE.Vector3())
const triNormal = useRef(new THREE.Vector3()) const triNormal = useRef(new THREE.Vector3())
const globalMinDistance = useRef(Infinity) const globalMinDistance = useRef(Number.POSITIVE_INFINITY)
const globalClosestPoint = useRef(new THREE.Vector3()) const globalClosestPoint = useRef(new THREE.Vector3())
const triHitPoint = useRef(new THREE.Vector3()) const triHitPoint = useRef(new THREE.Vector3())
const segHitPoint = useRef(new THREE.Vector3()) const segHitPoint = useRef(new THREE.Vector3())
@@ -286,7 +270,13 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const moving = currentLinVel.current.lengthSq() > 1e-6 const moving = currentLinVel.current.lengthSq() > 1e-6
const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6 const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6
if (!moving && isOnGround.current && !jump && !isOnMovingPlatform.current && !platformIsMoving) { if (
!moving &&
isOnGround.current &&
!jump &&
!isOnMovingPlatform.current &&
!platformIsMoving
) {
idleTime.current += delta idleTime.current += delta
if (idleTime.current > sleepTimeout) isSleeping.current = true if (idleTime.current > sleepTimeout) isSleeping.current = true
} else { } else {
@@ -340,7 +330,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
upAxis.current, upAxis.current,
) )
characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current) characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current)
characterModelRef.current.quaternion.slerp(characterModelTargetQuat.current, delta * turnSpeed) characterModelRef.current.quaternion.slerp(
characterModelTargetQuat.current,
delta * turnSpeed,
)
} }
const maxSpeed = run ? maxRunSpeed : maxWalkSpeed const maxSpeed = run ? maxRunSpeed : maxWalkSpeed
@@ -358,18 +351,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
) )
currentLinVel.current.add(deltaLinVel.current) currentLinVel.current.add(deltaLinVel.current)
} else if (isOnGround.current) { } else if (isOnGround.current) {
deltaLinVel.current.copy(currentLinVelOnPlane.current).clampLength(0, deceleration * friction * delta) deltaLinVel.current
.copy(currentLinVelOnPlane.current)
.clampLength(0, deceleration * friction * delta)
currentLinVel.current.sub(deltaLinVel.current) currentLinVel.current.sub(deltaLinVel.current)
} }
}, },
[acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin], [
acceleration,
airDragFactor,
counterAccFactor,
deceleration,
maxRunSpeed,
maxWalkSpeed,
turnSpeed,
characterOrigin,
],
) )
const updateSegmentBBox = useCallback(() => { const updateSegmentBBox = useCallback(() => {
if (!characterGroupRef.current) return if (!characterGroupRef.current) return
characterSegment.current.start.set(0, capsuleLength / 2, 0).add(characterGroupRef.current.position) characterSegment.current.start
characterSegment.current.end.set(0, -capsuleLength / 2, 0).add(characterGroupRef.current.position) .set(0, capsuleLength / 2, 0)
.add(characterGroupRef.current.position)
characterSegment.current.end
.set(0, -capsuleLength / 2, 0)
.add(characterGroupRef.current.position)
characterBbox.current characterBbox.current
.makeEmpty() .makeEmpty()
@@ -394,11 +402,18 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const collisionCheck = useCallback( const collisionCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => { (mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeCollisionCheck) return if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeCollisionCheck)
return
originMatrix.decompose(contactTempPos.current, contactTempQuat.current, contactTempScale.current) originMatrix.decompose(
contactTempPos.current,
contactTempQuat.current,
contactTempScale.current,
)
collideInvertMatrix.current.copy(originMatrix).invert() collideInvertMatrix.current.copy(originMatrix).invert()
localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current) localCharacterSegment.current
.copy(characterSegment.current)
.applyMatrix4(collideInvertMatrix.current)
scaledContactRadiusVec.current.set( scaledContactRadiusVec.current.set(
capsuleRadius / contactTempScale.current.x, capsuleRadius / contactTempScale.current.x,
@@ -445,7 +460,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
contactDepth.current = contactDepth.current =
capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current) capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current)
accumulatedContactNormal.current.addScaledVector(contactNormal.current, contactDepth.current) accumulatedContactNormal.current.addScaledVector(
contactNormal.current,
contactDepth.current,
)
accumulatedContactPoint.current.add(triContactPoint.current) accumulatedContactPoint.current.add(triContactPoint.current)
totalDepth.current += contactDepth.current totalDepth.current += contactDepth.current
triangleCount.current += 1 triangleCount.current += 1
@@ -492,14 +510,16 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const floatingCheck = useCallback( const floatingCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => { (mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeFloatHit) return if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeFloatHit) return
originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current) originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current)
floatInvertMatrix.current.copy(originMatrix).invert() floatInvertMatrix.current.copy(originMatrix).invert()
floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current) floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current)
floatNormalMatrix.current.getNormalMatrix(originMatrix) floatNormalMatrix.current.getNormalMatrix(originMatrix)
localFloatSensorSegment.current.copy(floatSensorSegment.current).applyMatrix4(floatInvertMatrix.current) localFloatSensorSegment.current
.copy(floatSensorSegment.current)
.applyMatrix4(floatInvertMatrix.current)
localFloatSensorBboxExpendPoint.current localFloatSensorBboxExpendPoint.current
.copy(floatSensorBboxExpendPoint.current) .copy(floatSensorBboxExpendPoint.current)
.applyMatrix4(floatInvertMatrix.current) .applyMatrix4(floatInvertMatrix.current)
@@ -517,20 +537,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1) localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current) localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
localMinDistance.current = Infinity localMinDistance.current = Number.POSITIVE_INFINITY
localClosestPoint.current.set(Infinity, Infinity, Infinity) localClosestPoint.current.set(
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
)
mesh.geometry.boundsTree.shapecast({ mesh.geometry.boundsTree.shapecast({
intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current), intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current),
intersectsTriangle: (tri) => { intersectsTriangle: (tri) => {
tri.closestPointToSegment(localFloatSensorSegment.current, triHitPoint.current, segHitPoint.current) tri.closestPointToSegment(
localUpAxis.current.copy(upAxis.current).applyMatrix3(floatNormalInverseMatrix.current).normalize() localFloatSensorSegment.current,
triHitPoint.current,
segHitPoint.current,
)
localUpAxis.current
.copy(upAxis.current)
.applyMatrix3(floatNormalInverseMatrix.current)
.normalize()
deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start) deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start)
deltaHit.current.divide(scaledFloatRadiusVec.current) deltaHit.current.divide(scaledFloatRadiusVec.current)
const totalLengthSq = deltaHit.current.lengthSq() const totalLengthSq = deltaHit.current.lengthSq()
const dot = deltaHit.current.dot(localUpAxis.current) const dot = deltaHit.current.dot(localUpAxis.current)
const verticalLength = Math.abs(dot) / ((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius) const verticalLength =
Math.abs(dot) /
((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius)
const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot)) const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot))
if (horizontalLength < 1 && verticalLength < 1) { if (horizontalLength < 1 && verticalLength < 1) {
@@ -560,9 +593,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const handleFloatingResponse = useCallback( const handleFloatingResponse = useCallback(
(meshes: THREE.Mesh[], jump: boolean, delta: number) => { (meshes: THREE.Mesh[], jump: boolean, delta: number) => {
if (meshes.length === 0) return if (meshes.length === 0) return
let shouldJump = jump
globalMinDistance.current = Infinity globalMinDistance.current = Number.POSITIVE_INFINITY
globalClosestPoint.current.set(Infinity, Infinity, Infinity) globalClosestPoint.current.set(
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
)
floatHitNormal.current.set(0, 1, 0) floatHitNormal.current.set(0, 1, 0)
isOnGround.current = false isOnGround.current = false
totalPlatformDeltaPos.current.set(0, 0, 0) totalPlatformDeltaPos.current.set(0, 0, 0)
@@ -574,7 +612,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
} }
} }
if (floatCheckType !== 'SHAPECAST' && floatRaycastCandidates.length > 0 && globalMinDistance.current === Infinity) { if (
floatCheckType !== 'SHAPECAST' &&
floatRaycastCandidates.length > 0 &&
globalMinDistance.current === Number.POSITIVE_INFINITY
) {
floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start) floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start)
floatRaycaster.current.ray.direction.copy(gravityDir.current) floatRaycaster.current.ray.direction.copy(gravityDir.current)
const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false) const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false)
@@ -582,23 +624,28 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
if (hit?.point) { if (hit?.point) {
globalClosestPoint.current.copy(hit.point) globalClosestPoint.current.copy(hit.point)
if (hit.face) { if (hit.face) {
floatHitNormal.current.copy(hit.face.normal).transformDirection(hit.object.matrixWorld).normalize() floatHitNormal.current
.copy(hit.face.normal)
.transformDirection(hit.object.matrixWorld)
.normalize()
} }
} }
} }
if (globalClosestPoint.current.x === Infinity) return if (globalClosestPoint.current.x === Number.POSITIVE_INFINITY) return
relativeHitPoint.current.copy(globalClosestPoint.current).sub(floatSensorSegment.current.start) relativeHitPoint.current
.copy(globalClosestPoint.current)
.sub(floatSensorSegment.current.start)
const currentDistance = relativeHitPoint.current.length() const currentDistance = relativeHitPoint.current.length()
currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current) currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current)
if (currentDistance < floatHeight + capsuleRadius) { if (currentDistance < floatHeight + capsuleRadius) {
isOnGround.current = true isOnGround.current = true
jump = false shouldJump = false
} }
if (!jump) { if (!shouldJump) {
const displacement = floatHeight + capsuleRadius - currentDistance const displacement = floatHeight + capsuleRadius - currentDistance
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current) const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
const springForce = displacement * floatSpringK const springForce = displacement * floatSpringK
@@ -608,7 +655,17 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta) currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta)
} }
}, },
[capsuleRadius, floatCheckType, floatDampingC, floatHeight, floatRaycastCandidates, floatSpringK, floatingCheck, gravity, mass], [
capsuleRadius,
floatCheckType,
floatDampingC,
floatHeight,
floatRaycastCandidates,
floatSpringK,
floatingCheck,
gravity,
mass,
],
) )
const updateCharacterWithPlatform = useCallback(() => { const updateCharacterWithPlatform = useCallback(() => {
@@ -646,8 +703,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
) )
const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), []) const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), [])
const addLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.add(velocity), []) const addLinVel = useCallback(
const setLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.copy(velocity), []) (velocity: THREE.Vector3) => currentLinVel.current.add(velocity),
[],
)
const setLinVel = useCallback(
(velocity: THREE.Vector3) => currentLinVel.current.copy(velocity),
[],
)
const setMovement = useCallback((movement: MovementInput) => { const setMovement = useCallback((movement: MovementInput) => {
if (movement.forward !== undefined) forwardState.current = movement.forward if (movement.forward !== undefined) forwardState.current = movement.forward
if (movement.backward !== undefined) backwardState.current = movement.backward if (movement.backward !== undefined) backwardState.current = movement.backward
@@ -682,7 +745,9 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end) debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end)
standPointRef.current?.position.copy(globalClosestPoint.current) standPointRef.current?.position.copy(globalClosestPoint.current)
if (characterGroupRef.current) { if (characterGroupRef.current) {
lookDirRef.current?.position.copy(characterGroupRef.current.position).addScaledVector(upAxis.current, 0.7) lookDirRef.current?.position
.copy(characterGroupRef.current.position)
.addScaledVector(upAxis.current, 0.7)
} }
lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current)) lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current))
inputDirRef.current?.position.copy(characterSegment.current.end) inputDirRef.current?.position.copy(characterSegment.current.end)
@@ -698,7 +763,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
if (paused || elapsedRef.current < delay) return if (paused || elapsedRef.current < delay) return
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
const keys = isInsideKeyboardControls && getKeys ? getKeys() : presetKeys const keys = getKeys() ?? presetKeys
const forward = forwardState.current || keys.forward const forward = forwardState.current || keys.forward
const backward = backwardState.current || keys.backward const backward = backwardState.current || keys.backward
const leftward = leftwardState.current || keys.leftward const leftward = leftwardState.current || keys.leftward
@@ -740,7 +805,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<group {...props} ref={characterGroupRef} dispose={null}> <group {...props} dispose={null} ref={characterGroupRef}>
{debug && ( {debug && (
<mesh ref={characterColliderRef}> <mesh ref={characterColliderRef}>
<capsuleGeometry args={colliderCapsuleArgs} /> <capsuleGeometry args={colliderCapsuleArgs} />
@@ -777,8 +842,8 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
<octahedronGeometry args={[0.1, 0]} /> <octahedronGeometry args={[0.1, 0]} />
<meshNormalMaterial /> <meshNormalMaterial />
</mesh> </mesh>
<arrowHelper ref={inputDirRef} args={[undefined, undefined, undefined, '#00f']} /> <arrowHelper args={[undefined, undefined, undefined, '#00f']} ref={inputDirRef} />
<arrowHelper ref={moveDirRef} args={[undefined, undefined, undefined, '#f00']} /> <arrowHelper args={[undefined, undefined, undefined, '#f00']} ref={moveDirRef} />
<mesh ref={standPointRef}> <mesh ref={standPointRef}>
<octahedronGeometry args={[0.12, 0]} /> <octahedronGeometry args={[0.12, 0]} />
<meshBasicMaterial color="red" opacity={0.2} transparent /> <meshBasicMaterial color="red" opacity={0.2} transparent />
View File
@@ -4092,7 +4092,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path} d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.slabFill} fill={isDeleteHovered ? palette.deleteFill : palette.slabFill}
fillRule="evenodd" fillRule="evenodd"
opacity={slabFillOpacity}
onClick={ onClick={
canSelectSlabs canSelectSlabs
? (event) => { ? (event) => {
@@ -4111,9 +4110,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
} }
onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined} onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined}
onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined} onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined}
opacity={slabFillOpacity}
pointerEvents={canSelectSlabs ? undefined : 'none'} pointerEvents={canSelectSlabs ? undefined : 'none'}
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
stroke="none" stroke="none"
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
/> />
{isSelected && !isDeleteHovered ? ( {isSelected && !isDeleteHovered ? (
<path <path
@@ -4168,7 +4168,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path} d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill} fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill}
fillRule="evenodd" fillRule="evenodd"
opacity={ceilingFillOpacity}
onClick={ onClick={
canSelectCeilings canSelectCeilings
? (event) => { ? (event) => {
@@ -4189,9 +4188,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined
} }
onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined} onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
opacity={ceilingFillOpacity}
pointerEvents={canSelectCeilings ? undefined : 'none'} pointerEvents={canSelectCeilings ? undefined : 'none'}
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
stroke="none" stroke="none"
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
/> />
{isSelected && !isDeleteHovered ? ( {isSelected && !isDeleteHovered ? (
<path <path
@@ -4298,8 +4298,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
<polygon <polygon
fill={`url(#${wallSelectionHatchId})`} fill={`url(#${wallSelectionHatchId})`}
opacity={1} opacity={1}
points={points}
pointerEvents="none" pointerEvents="none"
points={points}
/> />
) : null} ) : null}
</g> </g>
@@ -4427,8 +4427,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && ( {canSelectGeometry && (
<polygon <polygon
fill="transparent" fill="transparent"
points={points}
pointerEvents="all" pointerEvents="all"
points={points}
stroke="transparent" stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH} strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
@@ -4538,8 +4538,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && ( {canSelectGeometry && (
<polygon <polygon
fill="transparent" fill="transparent"
points={points}
pointerEvents="all" pointerEvents="all"
points={points}
stroke="transparent" stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH} strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
@@ -5218,8 +5218,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && ( {canSelectGeometry && (
<polygon <polygon
fill="transparent" fill="transparent"
points={points}
pointerEvents="all" pointerEvents="all"
points={points}
stroke="transparent" stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH} strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
@@ -5290,8 +5290,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={foldingPath} d={foldingPath}
fill="none" fill="none"
stroke={doorStroke} stroke={doorStroke}
strokeLinejoin="round"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'} strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
@@ -5963,6 +5963,12 @@ function FloorplanItemImage({
}) { }) {
const resolvedUrl = useResolvedAssetUrl(url) const resolvedUrl = useResolvedAssetUrl(url)
if (!resolvedUrl) return null if (!resolvedUrl) return null
// The PNG is captured with the modal's top-down camera (default up = +Y),
// so its pixel-right is world +X and pixel-up is world -Z. The plan SVG
// negates both axes (`toSvgX(v) = -v`, `toSvgY(v) = -v`), which together
// are a 180° rotation — so the captured image lands upside-down /
// mirrored when overlaid as-is. Bake that 180° into the image transform
// here; the panel / modal previews use the PNG directly and stay correct.
const rotationDeg = (-rotation * 180) / Math.PI + 180 const rotationDeg = (-rotation * 180) / Math.PI + 180
return ( return (
<g <g
@@ -6168,6 +6174,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
} }
points={points} points={points}
stroke={stroke} stroke={stroke}
strokeOpacity={1}
strokeWidth={ strokeWidth={
isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH
} }
@@ -6217,8 +6224,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
<polygon <polygon
fill={`url(#${wallSelectionHatchId})`} fill={`url(#${wallSelectionHatchId})`}
opacity={1} opacity={1}
points={points}
pointerEvents="none" pointerEvents="none"
points={points}
/> />
) : null} ) : null}
{itemDimensionMeasurements.length > 0 ? ( {itemDimensionMeasurements.length > 0 ? (
@@ -6308,8 +6315,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
<polygon <polygon
fill={fill} fill={fill}
fillOpacity={isDeleteHovered ? 0.82 : 0.92} fillOpacity={isDeleteHovered ? 0.82 : 0.92}
points={FLOORPLAN_SPAWN_ARROW_POINTS}
pointerEvents="none" pointerEvents="none"
points={FLOORPLAN_SPAWN_ARROW_POINTS}
stroke={stroke} stroke={stroke}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={0.055} strokeWidth={0.055}
@@ -7224,6 +7231,11 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
y2={endSvg.y} y2={endSvg.y}
/> />
<line <line
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
pointerEvents="stroke" pointerEvents="stroke"
stroke="transparent" stroke="transparent"
strokeLinecap="round" strokeLinecap="round"
@@ -7234,11 +7246,6 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
x2={endSvg.x} x2={endSvg.x}
y1={startSvg.y} y1={startSvg.y}
y2={endSvg.y} y2={endSvg.y}
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
/> />
</g> </g>
) )
@@ -9838,7 +9845,7 @@ export function FloorplanPanel() {
zoneVertexDragState != null || zoneVertexDragState != null ||
isPolygonDraftBuildActive isPolygonDraftBuildActive
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) { if (!(hasUserAdjustedViewportRef.current || transientFloorplanFit)) {
setViewport((current) => setViewport((current) =>
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport, floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
) )
@@ -10970,6 +10977,14 @@ export function FloorplanPanel() {
} }
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh]) }, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
// Subscribe to the live-transforms store so rotation/position changes that // Subscribe to the live-transforms store so rotation/position changes that
// *don't* go through pointer events still refresh the floorplan — e.g. R/T // *don't* go through pointer events still refresh the floorplan — e.g. R/T
// keyboard rotation during placement updates `useLiveTransforms` but emits // keyboard rotation during placement updates `useLiveTransforms` but emits
@@ -10985,14 +11000,6 @@ export function FloorplanPanel() {
return unsubscribe return unsubscribe
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh]) }, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
useEffect(() => { useEffect(() => {
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) { if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
return return
@@ -16132,17 +16139,13 @@ export function FloorplanPanel() {
onDuplicate: handleSelectedItemDuplicate, onDuplicate: handleSelectedItemDuplicate,
onMove: handleSelectedItemMove, onMove: handleSelectedItemMove,
}} }}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
opening={{ opening={{
position: selectedOpeningActionMenuPosition, position: selectedOpeningActionMenuPosition,
onDelete: handleSelectedOpeningDelete, onDelete: handleSelectedOpeningDelete,
onDuplicate: handleSelectedOpeningDuplicate, onDuplicate: handleSelectedOpeningDuplicate,
onMove: handleSelectedOpeningMove, onMove: handleSelectedOpeningMove,
}} }}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
roof={{ roof={{
position: selectedRoofActionMenuPosition, position: selectedRoofActionMenuPosition,
onDelete: handleSelectedRoofDelete, onDelete: handleSelectedRoofDelete,
@@ -16157,6 +16160,11 @@ export function FloorplanPanel() {
: handleSelectedSlabDelete, : handleSelectedSlabDelete,
onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove, onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove,
}} }}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
stair={{ stair={{
position: selectedStairActionMenuPosition, position: selectedStairActionMenuPosition,
onDelete: handleSelectedStairDelete, onDelete: handleSelectedStairDelete,
@@ -16168,7 +16176,6 @@ export function FloorplanPanel() {
onDelete: handleSelectedWallDelete, onDelete: handleSelectedWallDelete,
onMove: handleSelectedWallMove, onMove: handleSelectedWallMove,
}} }}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
/> />
{referenceScaleDraft && ( {referenceScaleDraft && (
@@ -16201,7 +16208,7 @@ export function FloorplanPanel() {
</div> </div>
<div className="mb-3 rounded-xl border border-border/70 bg-white/5 px-3 py-2"> <div className="mb-3 rounded-xl border border-border/70 bg-white/5 px-3 py-2">
<div className="text-muted-foreground text-[11px] uppercase tracking-wide"> <div className="text-[11px] text-muted-foreground uppercase tracking-wide">
Drawn line Drawn line
</div> </div>
<div className="mt-1 font-medium text-sm"> <div className="mt-1 font-medium text-sm">
@@ -16363,8 +16370,8 @@ export function FloorplanPanel() {
<FloorplanGuideLayer <FloorplanGuideLayer
activeGuideInteractionGuideId={activeGuideInteractionGuideId} activeGuideInteractionGuideId={activeGuideInteractionGuideId}
activeGuideInteractionMode={activeGuideInteractionMode} activeGuideInteractionMode={activeGuideInteractionMode}
guideUi={guideUi}
guides={displayGuides} guides={displayGuides}
guideUi={guideUi}
isInteractive={canInteractWithGuides} isInteractive={canInteractWithGuides}
onGuideSelect={handleGuideSelect} onGuideSelect={handleGuideSelect}
onGuideTranslateStart={handleGuideTranslateStart} onGuideTranslateStart={handleGuideTranslateStart}
@@ -16385,6 +16392,8 @@ export function FloorplanPanel() {
hoveredSlabId={hoveredSlabId} hoveredSlabId={hoveredSlabId}
hoveredWallId={hoveredWallId} hoveredWallId={hoveredWallId}
isDeleteMode={isDeleteMode} isDeleteMode={isDeleteMode}
isGuideTraceVisible={isGuideTraceVisible}
metersPerUnit={calibratedMetersPerUnit}
onCeilingDoubleClick={handleCeilingDoubleClick} onCeilingDoubleClick={handleCeilingDoubleClick}
onCeilingHoverChange={handleCeilingHoverChange} onCeilingHoverChange={handleCeilingHoverChange}
onCeilingSelect={handleCeilingSelect} onCeilingSelect={handleCeilingSelect}
@@ -16401,11 +16410,9 @@ export function FloorplanPanel() {
openingsPolygons={openingsPolygons} openingsPolygons={openingsPolygons}
palette={palette} palette={palette}
selectedIdSet={selectedIdSet} selectedIdSet={selectedIdSet}
slabSelectionHatchId={slabSelectionHatchId}
slabPolygons={displaySlabPolygons} slabPolygons={displaySlabPolygons}
slabSelectionHatchId={slabSelectionHatchId}
unit={unit} unit={unit}
metersPerUnit={calibratedMetersPerUnit}
isGuideTraceVisible={isGuideTraceVisible}
wallPolygons={displayWallPolygons} wallPolygons={displayWallPolygons}
wallSelectionHatchId={wallSelectionHatchId} wallSelectionHatchId={wallSelectionHatchId}
/> />
@@ -16483,8 +16490,8 @@ export function FloorplanPanel() {
<FloorplanReferenceScaleLayer <FloorplanReferenceScaleLayer
draft={referenceScaleDraft} draft={referenceScaleDraft}
guideUi={guideUi}
guides={displayGuides} guides={displayGuides}
guideUi={guideUi}
palette={palette} palette={palette}
unit={unit} unit={unit}
unitsPerPixel={floorplanUnitsPerPixel} unitsPerPixel={floorplanUnitsPerPixel}
+68 -53
View File
@@ -20,6 +20,7 @@ import {
import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
import { useAutoFrame } from '../../hooks/use-auto-frame'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard' import { useKeyboard } from '../../hooks/use-keyboard'
import { import {
@@ -64,6 +65,7 @@ import { Grid } from './grid'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels' import { SiteEdgeLabels } from './site-edge-labels'
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label' import { WallMeasurementLabel } from './wall-measurement-label'
@@ -76,12 +78,12 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const EDITOR_HOVER_STYLES: HoverStyles = { const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true }, default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false }, delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true }, 'paint-ready': { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true },
'paint-disabled': { 'paint-disabled': {
visibleColor: 0x94_a3b8, visibleColor: 0x94_a3_b8,
hiddenColor: 0x47_5569, hiddenColor: 0x47_55_69,
strength: 4, strength: 4,
pulse: false, pulse: false,
}, },
@@ -462,7 +464,7 @@ function ViewerCanvasControlsHint({
<div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2"> <div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
<section <section
aria-label="Camera controls hint" aria-label="Camera controls hint"
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl" className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-elevation-4 backdrop-blur-xl"
> >
<div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18"> <div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
{hints.map((hint) => ( {hints.map((hint) => (
@@ -753,7 +755,7 @@ function PaintCursorLayer({
(activePaintMaterial.material !== undefined || (activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined), activePaintMaterial.materialPreset !== undefined),
) )
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}` const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
const icon = 'mdi:format-color-fill' const icon = 'mdi:format-color-fill'
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -786,16 +788,16 @@ function PaintCursorLayer({
const ViewerCanvas = memo(function ViewerCanvas({ const ViewerCanvas = memo(function ViewerCanvas({
isVersionPreviewMode, isVersionPreviewMode,
isLoading, isLoading,
isFirstPersonMode,
hasLoadedInitialScene, hasLoadedInitialScene,
showLoader, showLoader,
isFirstPersonMode,
onThumbnailCapture, onThumbnailCapture,
}: { }: {
isVersionPreviewMode: boolean isVersionPreviewMode: boolean
isLoading: boolean isLoading: boolean
isFirstPersonMode: boolean
hasLoadedInitialScene: boolean hasLoadedInitialScene: boolean
showLoader: boolean showLoader: boolean
isFirstPersonMode: boolean
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
}) { }) {
const viewMode = useEditor((s) => s.viewMode) const viewMode = useEditor((s) => s.viewMode)
@@ -837,7 +839,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
window.removeEventListener('pointermove', handlePointerMove) window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp) window.removeEventListener('pointerup', handlePointerUp)
} }
}, [setFloorplanPaneRatio]) }, [])
useEffect(() => { useEffect(() => {
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed()) setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
@@ -940,7 +942,9 @@ export default function Editor({
commandPaletteEmptyAction, commandPaletteEmptyAction,
}: EditorProps) { }: EditorProps) {
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode }) useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
const { isLoadingSceneRef } = useAutoSave({ const { isLoadingSceneRef } = useAutoSave({
onSave, onSave,
onDirty, onDirty,
@@ -951,8 +955,7 @@ export default function Editor({
const [isSceneLoading, setIsSceneLoading] = useState(false) const [isSceneLoading, setIsSceneLoading] = useState(false)
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId) const isCaptureMode = useEditor((s) => s.isCaptureMode)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
const sidebarWidth = useSidebarStore((s) => s.width) const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -970,39 +973,6 @@ export default function Editor({
} }
}, [projectId]) }, [projectId])
useEffect(() => {
const wasFirstPersonMode = wasFirstPersonModeRef.current
wasFirstPersonModeRef.current = isFirstPersonMode
if (isFirstPersonMode && !wasFirstPersonMode) {
const viewer = useViewer.getState()
firstPersonPreviousLevelRef.current = viewer.selection.levelId
viewer.setCameraMode('perspective')
viewer.setWallMode('up')
viewer.setWalkthroughMode(true)
viewer.setSelection({ selectedIds: [], zoneId: null })
return
}
if (!(wasFirstPersonMode && !isFirstPersonMode)) return
const viewer = useViewer.getState()
const previousLevelId = firstPersonPreviousLevelRef.current
firstPersonPreviousLevelRef.current = null
viewer.setWalkthroughMode(false)
if (!previousLevelId) return
const previousLevelNode = useScene.getState().nodes[previousLevelId]
if (previousLevelNode?.type === 'level') {
viewer.setSelection({
levelId: previousLevelId,
zoneId: null,
selectedIds: [],
})
}
}, [isFirstPersonMode])
// Load scene on mount (or when onLoad identity changes, e.g. project switch) // Load scene on mount (or when onLoad identity changes, e.g. project switch)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -1064,6 +1034,42 @@ export default function Editor({
const showLoader = isLoading || isSceneLoading const showLoader = isLoading || isSceneLoading
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
useEffect(() => {
const wasFirstPersonMode = wasFirstPersonModeRef.current
wasFirstPersonModeRef.current = isFirstPersonMode
if (isFirstPersonMode && !wasFirstPersonMode) {
const viewer = useViewer.getState()
firstPersonPreviousLevelRef.current = viewer.selection.levelId
viewer.setCameraMode('perspective')
viewer.setWallMode('up')
viewer.setWalkthroughMode(true)
viewer.setSelection({ selectedIds: [], zoneId: null })
return
}
if (!(wasFirstPersonMode && !isFirstPersonMode)) return
const viewer = useViewer.getState()
const previousLevelId = firstPersonPreviousLevelRef.current
firstPersonPreviousLevelRef.current = null
viewer.setWalkthroughMode(false)
if (!previousLevelId) return
const previousLevelNode = useScene.getState().nodes[previousLevelId]
if (previousLevelNode?.type === 'level') {
viewer.setSelection({
levelId: previousLevelId,
zoneId: null,
selectedIds: [],
})
}
}, [isFirstPersonMode])
const previewViewerContent = ( const previewViewerContent = (
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default"> <Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
<ExportManager /> <ExportManager />
@@ -1135,21 +1141,24 @@ export default function Editor({
navbarSlot={navbarSlot} navbarSlot={navbarSlot}
overlays={ overlays={
<> <>
<FloatingLevelSelector /> {!isCaptureMode && <FloatingLevelSelector />}
{!isVersionPreviewMode && ( {!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<ActionMenu /> <ActionMenu />
</div> </div>
)} )}
{!isVersionPreviewMode && ( {!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<PanelManager /> <PanelManager />
</div> </div>
)} )}
<div className="pointer-events-auto"> {!isCaptureMode && (
<HelperManager /> <div className="pointer-events-auto">
</div> <HelperManager />
</div>
)}
{viewerBanner} {viewerBanner}
{projectId ? <SnapshotCaptureOverlay projectId={projectId} /> : null}
</> </>
} }
renderTabContent={renderTabContent} renderTabContent={renderTabContent}
@@ -1159,14 +1168,14 @@ export default function Editor({
viewerToolbarLeft={viewerToolbarLeft} viewerToolbarLeft={viewerToolbarLeft}
viewerToolbarRight={viewerToolbarRight} viewerToolbarRight={viewerToolbarRight}
/> />
<EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
{/* First-person overlay — rendered on top of normal layout */} {/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && ( {isFirstPersonMode && (
<div className="pointer-events-none fixed inset-0 z-50"> <div className="pointer-events-none fixed inset-0 z-50">
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} /> <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
</div> </div>
)} )}
<EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
</> </>
)} )}
</PresetsProvider> </PresetsProvider>
@@ -1222,6 +1231,12 @@ export default function Editor({
<HelperManager /> <HelperManager />
</div> </div>
</ViewerOverlays> </ViewerOverlays>
{/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && (
<div className="pointer-events-none fixed inset-0 z-50">
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
</div>
)}
</> </>
)} )}
</div> </div>
+2 -2
View File
@@ -341,7 +341,7 @@ function applySingleSurfacePaintPreview(
if (node.type === 'ceiling') { if (node.type === 'ceiling') {
const root = getRegisteredMesh(node.id) const root = getRegisteredMesh(node.id)
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
if (!root || !overlay) return null if (!(root && overlay)) return null
const previewColor = const previewColor =
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ?? getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
@@ -999,7 +999,6 @@ export const SelectionManager = () => {
'roof-segment', 'roof-segment',
'stair', 'stair',
'stair-segment', 'stair-segment',
'spawn',
'window', 'window',
'door', 'door',
'zone', 'zone',
@@ -1392,6 +1391,7 @@ export const SelectionManager = () => {
'roof-segment', 'roof-segment',
'stair', 'stair',
'stair-segment', 'stair-segment',
'spawn',
'window', 'window',
'door', 'door',
'zone', 'zone',
@@ -0,0 +1,465 @@
'use client'
import { emitter } from '@pascal-app/core'
import { Camera, Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useIsMobile } from '../../hooks/use-mobile'
import { triggerSFX } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
type CaptureMode = 'standard' | 'viewport' | 'area'
type CaptureState = 'idle' | 'capturing' | 'saved'
interface DragPoint {
x: number
y: number
}
interface Drag {
start: DragPoint
end: DragPoint
}
function getResolution(
mode: CaptureMode,
overlayEl: HTMLDivElement | null,
drag: Drag | null,
): { w: number; h: number } | null {
if (mode === 'standard') return { w: 1920, h: 1080 }
if (!overlayEl) return null
const rect = overlayEl.getBoundingClientRect()
const dpr = Math.min(window.devicePixelRatio, 1.5)
if (mode === 'viewport') {
return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
}
if (mode === 'area' && drag) {
const w = Math.abs(drag.end.x - drag.start.x)
const h = Math.abs(drag.end.y - drag.start.y)
if (w < 4 || h < 4) return null
return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
}
return null
}
export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const setCaptureMode = useEditor((s) => s.setCaptureMode)
const isMobile = useIsMobile()
const [mode, setMode] = useState<CaptureMode>('standard')
const [drag, setDrag] = useState<Drag | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [captureState, setCaptureState] = useState<CaptureState>('idle')
const overlayRef = useRef<HTMLDivElement>(null)
// Dismiss on Esc
useEffect(() => {
if (!isCaptureMode) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setCaptureMode(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isCaptureMode, setCaptureMode])
// Reset local state when entering capture mode
useEffect(() => {
if (isCaptureMode) {
setMode('standard')
setDrag(null)
setIsDragging(false)
setCaptureState('idle')
}
}, [isCaptureMode])
// Listen for snapshot saved to show feedback then exit
useEffect(() => {
const handler = () => {
setCaptureState('saved')
setTimeout(() => {
setCaptureMode(false)
setCaptureState('idle')
}, 1500)
}
emitter.on('snapshot:saved', handler)
return () => emitter.off('snapshot:saved', handler)
}, [setCaptureMode])
const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])
// Tracks whether the active drag is a "move entire rect" gesture
const moveStartRef = useRef<{ pt: DragPoint; drag: Drag } | null>(null)
// Area drag handlers — relative to the overlay container
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (mode !== 'area' || captureState !== 'idle') return
e.preventDefault()
const rect = overlayRef.current!.getBoundingClientRect()
const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }
// If clicking inside an existing selection → move mode
if (drag) {
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) {
moveStartRef.current = { pt, drag }
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
return
}
}
// Outside / no selection → start new drag
moveStartRef.current = null
setDrag({ start: pt, end: pt })
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
},
[mode, captureState, drag],
)
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging) return
const rect = overlayRef.current!.getBoundingClientRect()
const pt = {
x: Math.max(0, Math.min(e.clientX - rect.left, rect.width)),
y: Math.max(0, Math.min(e.clientY - rect.top, rect.height)),
}
if (moveStartRef.current) {
// Move mode: translate the whole rect by the delta
const { pt: origin, drag: snapshot } = moveStartRef.current
const dx = pt.x - origin.x
const dy = pt.y - origin.y
setDrag({
start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy },
end: { x: snapshot.end.x + dx, y: snapshot.end.y + dy },
})
} else {
setDrag((d) => (d ? { start: d.start, end: pt } : null))
}
},
[isDragging],
)
const onPointerUp = useCallback(() => {
const wasMoving = moveStartRef.current !== null
setIsDragging(false)
moveStartRef.current = null
// Clear the rect if the user just clicked without drawing (not a move gesture)
if (!wasMoving) {
setDrag((d) => {
if (!d) return null
const w = Math.abs(d.end.x - d.start.x)
const h = Math.abs(d.end.y - d.start.y)
return w < 4 && h < 4 ? null : d
})
}
}, [])
// Corner-handle resize: re-anchor to the opposite corner then reuse the same drag machinery
const onCornerPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>, cornerIndex: number) => {
if (captureState !== 'idle' || !drag) return
e.stopPropagation()
e.preventDefault()
moveStartRef.current = null
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
// anchor = opposite corner; dragged = current corner
const corners: [DragPoint, DragPoint][] = [
[
{ x: x1, y: y1 },
{ x: x0, y: y0 },
], // TL → anchor BR
[
{ x: x0, y: y1 },
{ x: x1, y: y0 },
], // TR → anchor BL
[
{ x: x1, y: y0 },
{ x: x0, y: y1 },
], // BL → anchor TR
[
{ x: x0, y: y0 },
{ x: x1, y: y1 },
], // BR → anchor TL
]
const [anchor, current] = corners[cornerIndex]!
setDrag({ start: anchor, end: current })
setIsDragging(true)
},
[captureState, drag],
)
const handleCapture = useCallback(() => {
if (captureState !== 'idle') return
let cropRegion: { x: number; y: number; width: number; height: number } | undefined
if (mode === 'area' && drag && overlayRef.current) {
const rect = overlayRef.current.getBoundingClientRect()
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const w = Math.abs(drag.end.x - drag.start.x)
const h = Math.abs(drag.end.y - drag.start.y)
cropRegion = {
x: x0 / rect.width,
y: y0 / rect.height,
width: w / rect.width,
height: h / rect.height,
}
}
setCaptureState('capturing')
triggerSFX('sfx:snapshot-capture')
emitter.emit('camera-controls:generate-thumbnail', {
projectId,
captureMode: mode,
cropRegion,
})
}, [captureState, mode, drag, projectId])
if (!isCaptureMode) return null
const resolution = getResolution(mode, overlayRef.current, drag)
// Area selection rect (CSS px, relative to overlay)
const selectionStyle =
mode === 'area' && drag
? {
left: Math.min(drag.start.x, drag.end.x),
top: Math.min(drag.start.y, drag.end.y),
width: Math.abs(drag.end.x - drag.start.x),
height: Math.abs(drag.end.y - drag.start.y),
}
: null
const hasSelection =
selectionStyle != null && selectionStyle.width > 3 && selectionStyle.height > 3
const captureDisabled = captureState !== 'idle' || (mode === 'area' && !hasSelection)
return (
<div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
{/* Area mode: dim layer + crosshair cursor + drag-to-select */}
{mode === 'area' && (
<div
className="pointer-events-auto absolute inset-0 bg-black/30"
onPointerDown={onPointerDown}
onPointerMove={(e) => {
onPointerMove(e)
// Update cursor: 'move' when hovering inside an existing selection
if (!isDragging && drag && overlayRef.current) {
const rect = overlayRef.current.getBoundingClientRect()
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
e.currentTarget.style.cursor =
px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair'
}
}}
onPointerUp={onPointerUp}
style={{ cursor: 'crosshair' }}
>
{/* "No selection" hint */}
{!selectionStyle && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
Drag the area you want to capture
</span>
</div>
)}
{/* Selection rect */}
{selectionStyle && (
<div
style={{
position: 'absolute',
left: selectionStyle.left,
top: selectionStyle.top,
width: selectionStyle.width,
height: selectionStyle.height,
pointerEvents: 'none',
boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
border: '2px dashed rgba(255,255,255,0.85)',
background: 'rgba(255,255,255,0.04)',
}}
>
{/* Corner handles */}
{(
[
{ pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
{ pos: { top: -5, right: -5 }, cursor: 'nesw-resize' },
{ pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' },
{ pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' },
] as const
).map(({ pos, cursor }, i) => (
<div
key={i}
onPointerDown={(e) => onCornerPointerDown(e, i)}
style={{
position: 'absolute',
width: 10,
height: 10,
borderRadius: '50%',
background: 'white',
boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
pointerEvents: 'auto',
cursor,
...pos,
}}
/>
))}
</div>
)}
</div>
)}
{/* Top-right dismiss button (icon-only on mobile) */}
<div className="pointer-events-auto absolute top-4 right-4">
<button
aria-label="Close capture mode"
className="flex items-center gap-1.5 rounded-full border border-white/20 bg-black/60 px-3 py-1.5 text-white/80 text-xs backdrop-blur-sm transition-colors hover:bg-black/80 hover:text-white"
onClick={dismiss}
type="button"
>
<X className="h-3 w-3" />
{!isMobile && 'Esc to cancel'}
</button>
</div>
{/* Bottom-center mode toolbar */}
<div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
{(() => {
const modeButtons = (
<>
<ModeButton
active={mode === 'standard'}
badge="16:9"
icon={<Monitor className="h-3.5 w-3.5" />}
label="Standard"
onClick={() => {
setMode('standard')
setDrag(null)
}}
/>
<ModeButton
active={mode === 'viewport'}
icon={<Maximize2 className="h-3.5 w-3.5" />}
label="Viewport"
onClick={() => {
setMode('viewport')
setDrag(null)
}}
/>
<ModeButton
active={mode === 'area'}
icon={<Crop className="h-3.5 w-3.5" />}
label="Area"
onClick={() => setMode('area')}
/>
</>
)
const resolutionDisplay = (
<span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
{resolution ? `${resolution.w} × ${resolution.h}` : '—'}
</span>
)
const captureButton = (
<button
className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-xs transition-opacity disabled:opacity-50"
disabled={captureDisabled}
onClick={handleCapture}
type="button"
>
{captureState === 'capturing' ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Capturing
</>
) : captureState === 'saved' ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Camera className="h-3.5 w-3.5" />
Capture
</>
)}
</button>
)
if (isMobile) {
return (
<div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
<div className="flex items-center justify-center gap-1">{modeButtons}</div>
<div className="flex items-center justify-center gap-2 border-white/10 border-t pt-2">
{resolutionDisplay}
{captureButton}
</div>
</div>
)
}
return (
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
{modeButtons}
<div className="mx-1 h-4 w-px bg-white/10" />
{resolutionDisplay}
<div className="mx-1 h-4 w-px bg-white/10" />
{captureButton}
</div>
)
})()}
</div>
</div>
)
}
function ModeButton({
active,
icon,
label,
badge,
onClick,
}: {
active: boolean
icon: React.ReactNode
label: string
badge?: string
onClick: () => void
}) {
return (
<button
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-colors ${
active ? 'bg-white/15 text-white ring-1 ring-white/20' : 'text-white/50 hover:text-white/90'
}`}
onClick={onClick}
type="button"
>
{icon}
{label}
{badge && (
<span className="rounded-sm bg-white/10 px-1 py-0.5 font-medium text-[10px] text-white/40 leading-none">
{badge}
</span>
)}
</button>
)
}
@@ -1,6 +1,6 @@
'use client' 'use client'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core' import { emitter, sceneRegistry } from '@pascal-app/core'
import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer' import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
import type { CameraControls } from '@react-three/drei' import type { CameraControls } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
@@ -28,7 +28,6 @@ import { EDITOR_LAYER } from '../../lib/constants'
const THUMBNAIL_WIDTH = 1920 const THUMBNAIL_WIDTH = 1920
const THUMBNAIL_HEIGHT = 1080 const THUMBNAIL_HEIGHT = 1080
const AUTO_SAVE_DELAY = 10_000
export interface SnapshotCameraData { export interface SnapshotCameraData {
position: [number, number, number] position: [number, number, number]
@@ -49,8 +48,6 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const mainCamera = useThree((state) => state.camera) const mainCamera = useThree((state) => state.camera)
const controls = useThree((state) => state.controls) as CameraControls | null const controls = useThree((state) => state.controls) as CameraControls | null
const isGenerating = useRef(false) const isGenerating = useRef(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingAutoRef = useRef(false)
const onThumbnailCaptureRef = useRef(onThumbnailCapture) const onThumbnailCaptureRef = useRef(onThumbnailCapture)
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null) const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
@@ -61,7 +58,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
onThumbnailCaptureRef.current = onThumbnailCapture onThumbnailCaptureRef.current = onThumbnailCapture
}, [onThumbnailCapture]) }, [onThumbnailCapture])
// Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture. // Build the thumbnail camera, SSGI pipeline, and render target once reused on every capture.
useEffect(() => { useEffect(() => {
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000) const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
cam.layers.disable(EDITOR_LAYER) cam.layers.disable(EDITOR_LAYER)
@@ -75,7 +72,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (!mounted) return if (!mounted) return
// pass() handles MRT internally for all material types, including custom // pass() handles MRT internally for all material types, including custom
// shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials. // shaders unlike renderer.setMRT() which crashes on non-NodeMaterials.
// pass() also respects camera.layers, so EDITOR_LAYER objects are filtered. // pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
const scenePass = pass(scene, cam) const scenePass = pass(scene, cam)
scenePass.setMRT( scenePass.setMRT(
@@ -117,15 +114,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const ao = (denoisePass as any).r const ao = (denoisePass as any).r
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a) const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
// FXAA requires a texture node as input, convertToTexture renders finalOutput // FXAA requires a texture node as input; convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbor UV offsets. // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
const aaOutput = fxaa(convertToTexture(finalOutput)) const aaOutput = fxaa(convertToTexture(finalOutput))
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer) const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
pipeline.outputNode = aaOutput pipeline.outputNode = aaOutput
pipelineRef.current = pipeline pipelineRef.current = pipeline
// Dedicated render target, pipeline outputs here instead of the canvas, // Dedicated render target pipeline outputs here instead of the canvas,
// so R3F's main render loop can never overwrite our capture. // so R3F's main render loop can never overwrite our capture.
const { width, height } = gl.domElement const { width, height } = gl.domElement
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true }) renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
@@ -176,7 +173,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
thumbnailCamera.aspect = width / height thumbnailCamera.aspect = width / height
thumbnailCamera.updateProjectionMatrix() thumbnailCamera.updateProjectionMatrix()
// Capture camera data for snapshot storage. // Capture camera data for snapshot storage
const pos = mainCamera.position const pos = mainCamera.position
let tgt: [number, number, number] | null = null let tgt: [number, number, number] | null = null
if (controls && 'getTarget' in controls) { if (controls && 'getTarget' in controls) {
@@ -192,7 +189,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }), ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
} }
// For auto-save, snap levels to stacked positions and reset levelMode. // For auto-save: snap levels to stacked positions and reset levelMode
let restoreLevelMode: (() => void) | null = null let restoreLevelMode: (() => void) | null = null
let restoreLevels: () => void = () => {} let restoreLevels: () => void = () => {}
if (snapLevels) { if (snapLevels) {
@@ -205,7 +202,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
} }
// Hide scan and guide nodes directly so they are excluded from the // Hide scan and guide nodes directly so they are excluded from the
// thumbnail regardless of whether ScanSystem or GuideSystem listeners are // thumbnail regardless of whether ScanSystem/GuideSystem listeners are
// registered. Returns a function that restores the original visibility. // registered. Returns a function that restores the original visibility.
const restoreNodeVisibility = (() => { const restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>() const saved = new Map<THREE.Object3D, boolean>()
@@ -231,7 +228,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (pipelineRef.current && renderTargetRef.current) { if (pipelineRef.current && renderTargetRef.current) {
const rt = renderTargetRef.current const rt = renderTargetRef.current
// Resize RT if the canvas dimensions changed. // Resize RT if the canvas dimensions changed
if (rt.width !== width || rt.height !== height) { if (rt.width !== width || rt.height !== height) {
rt.setSize(width, height) rt.setSize(width, height)
} }
@@ -248,7 +245,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
emitter.emit('thumbnail:after-capture', undefined) emitter.emit('thumbnail:after-capture', undefined)
// Restore level positions, levelMode, and node visibility immediately after the // Restore level positions, levelMode, and node visibility immediately after the
// render, before the async GPU readback. // render before the async GPU readback.
restoreLevels() restoreLevels()
restoreLevelMode?.() restoreLevelMode?.()
restoreNodeVisibility() restoreNodeVisibility()
@@ -339,6 +336,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH) offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: 'image/png' }) blob = await offscreen.convertToBlob({ type: 'image/png' })
} else { } else {
// Standard: center-crop to 1920×1080 aspect ratio
const srcAspect = width / height const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0, let sx = 0,
@@ -364,7 +362,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (captureMode !== undefined) cameraData.captureMode = captureMode if (captureMode !== undefined) cameraData.captureMode = captureMode
cameraData.resolution = { w: outW, h: outH } cameraData.resolution = { w: outW, h: outH }
} else { } else {
// Fallback: plain render directly to the canvas. // Fallback: plain render directly to the canvas
emitter.emit('thumbnail:before-capture', undefined) emitter.emit('thumbnail:before-capture', undefined)
gl.render(scene, thumbnailCamera) gl.render(scene, thumbnailCamera)
emitter.emit('thumbnail:after-capture', undefined) emitter.emit('thumbnail:after-capture', undefined)
@@ -450,10 +448,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
) )
// Thumbnail request via emitter. Two call shapes: // Thumbnail request via emitter. Two call shapes:
// - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures // - user-driven capture: `{ projectId, captureMode, cropRegion }` captures
// the current pose with the supplied crop. // the current pose with the supplied crop.
// - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to // - host-driven auto-save: `{ projectId, snapLevels: true }` snaps levels
// their true positions first for a consistent auto-thumbnail angle. // to their true positions first for a consistent auto-thumbnail angle.
// The caller owns policy (when to fire, whether the tab is visible).
useEffect(() => { useEffect(() => {
if (!onThumbnailCapture) return if (!onThumbnailCapture) return
@@ -469,49 +468,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail) return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
}, [generate, onThumbnailCapture]) }, [generate, onThumbnailCapture])
// OSS adaptation: keep local debounced auto-capture behavior because the // Go-to-camera: animate camera to a saved snapshot position/target
// community host-side autosave hook is not part of this repo.
useEffect(() => {
if (!onThumbnailCapture) return
const triggerNow = () => {
void generate(true)
}
const scheduleOrDefer = () => {
if (document.visibilityState === 'visible') {
triggerNow()
} else {
pendingAutoRef.current = true
}
}
const onSceneChange = () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && pendingAutoRef.current) {
pendingAutoRef.current = false
triggerNow()
}
}
const unsubscribe = useScene.subscribe((state, prevState) => {
if (state.nodes !== prevState.nodes) onSceneChange()
})
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
unsubscribe()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [generate, onThumbnailCapture])
// Go-to-camera: animate camera to a saved snapshot position or target.
useEffect(() => { useEffect(() => {
const handler = ({ const handler = ({
position, position,
@@ -144,11 +144,11 @@ export function useFloorplanBackgroundPlacement({
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
if (!roofDraftStart) { if (roofDraftStart) {
clearRoofPlacementDraft()
} else {
setRoofDraftStart(snappedPoint) setRoofDraftStart(snappedPoint)
setRoofDraftEnd(snappedPoint) setRoofDraftEnd(snappedPoint)
} else {
clearRoofPlacementDraft()
} }
return true return true
} }
View File
@@ -1,265 +0,0 @@
'use client'
import { useScene } from '@pascal-app/core'
import { ImageIcon, MessageSquare, X } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { Button } from './ui/primitives/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/primitives/dialog'
const MAX_IMAGES = 5
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
type ImagePreview = { file: File; url: string }
export function FeedbackDialog({
projectId: projectIdProp,
onSubmit,
}: {
projectId?: string
onSubmit?: (data: {
message: string
projectId?: string
sceneGraph: unknown
images: File[]
}) => Promise<{ success: boolean; error?: string }>
}) {
const projectId = projectIdProp
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [images, setImages] = useState<ImagePreview[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounter = useRef(0)
const handleOpen = () => {
setOpen(true)
setSent(false)
setError(null)
setMessage('')
setImages([])
setIsDragging(false)
dragCounter.current = 0
}
const handleClose = () => {
if (isSubmitting) return
setOpen(false)
images.forEach((img) => {
URL.revokeObjectURL(img.url)
})
}
const addFiles = useCallback((files: FileList | File[]) => {
const incoming = Array.from(files).filter(
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
)
setImages((prev) => {
const remaining = MAX_IMAGES - prev.length
const added = incoming.slice(0, remaining).map((file) => ({
file,
url: URL.createObjectURL(file),
}))
return [...prev, ...added]
})
}, [])
const removeImage = (index: number) => {
setImages((prev) => {
const img = prev[index]
if (img) URL.revokeObjectURL(img.url)
return prev.filter((_, i) => i !== index)
})
}
// ── Drag handlers (on the entire dialog content) ──
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current++
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true)
}
}
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current--
if (dragCounter.current === 0) {
setIsDragging(false)
}
}
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current = 0
setIsDragging(false)
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
if (!onSubmit) return
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds }
const result = await onSubmit({
message,
projectId,
sceneGraph,
images: images.map((img) => img.file),
})
if (result.success) {
setSent(true)
setTimeout(() => setOpen(false), 1500)
} else {
setError(result.error ?? 'Something went wrong')
}
} finally {
setIsSubmitting(false)
}
}
return (
<>
<button
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
onClick={handleOpen}
>
<MessageSquare className="h-4 w-4" />
Feedback
</button>
<Dialog onOpenChange={handleClose} open={open}>
<DialogContent
className="sm:max-w-[460px]"
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* Drag overlay — only visible when dragging files over the dialog */}
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-primary/50 border-dashed bg-primary/5 backdrop-blur-sm transition-all">
<div className="flex flex-col items-center gap-2 text-primary/70">
<ImageIcon className="h-8 w-8" />
<p className="font-medium text-sm">Drop images here</p>
</div>
</div>
)}
<DialogHeader>
<DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>We&apos;d love to hear your thoughts</DialogDescription>
</DialogHeader>
{sent ? (
<p className="py-4 text-center text-muted-foreground text-sm">
Thanks for your feedback!
</p>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="font-medium text-sm" htmlFor="feedback-message">
Your feedback
</label>
<textarea
autoFocus
className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isSubmitting}
id="feedback-message"
onChange={(e) => setMessage(e.target.value)}
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
rows={5}
value={message}
/>
</div>
{/* Image thumbnails */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => (
<div
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
key={img.url}
>
<img alt="" className="h-full w-full object-cover" src={img.url} />
<button
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => removeImage(i)}
type="button"
>
<X className="h-4 w-4 text-white" />
</button>
</div>
))}
</div>
)}
{error && <p className="text-destructive text-sm">{error}</p>}
<div className="flex items-center justify-between">
{/* Subtle attach button */}
<button
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40"
disabled={isSubmitting || images.length >= MAX_IMAGES}
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImageIcon className="h-3.5 w-3.5" />
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
</button>
<input
accept="image/*"
className="hidden"
multiple
onChange={(e) => {
if (e.target.files) addFiles(e.target.files)
e.target.value = ''
}}
ref={fileInputRef}
type="file"
/>
<div className="flex gap-2">
<Button
disabled={isSubmitting}
onClick={handleClose}
type="button"
variant="outline"
>
Cancel
</Button>
<Button disabled={isSubmitting || !message.trim() || !onSubmit} type="submit">
{isSubmitting ? 'Sending...' : 'Send Feedback'}
</Button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}
@@ -1,280 +0,0 @@
'use client'
import { Howl } from 'howler'
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Slider } from '../components/ui/slider'
import { cn } from '../lib/utils'
import useAudio from '../store/use-audio'
const PLAYLIST = [
{
title: 'Ballroom in Miniature',
file: '/audios/radios/classic/Ballroom in Miniature.mp3',
},
{
title: 'Blueprints in Springtime',
file: '/audios/radios/classic/Blueprints in Springtime.mp3',
},
{
title: 'Clockwork Tea Party',
file: '/audios/radios/classic/Clockwork Tea Party.mp3',
},
{
title: 'Clockwork Tea Party (Alternate)',
file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
},
{
title: 'Clockwork Teacups',
file: '/audios/radios/classic/Clockwork Teacups.mp3',
},
{
title: 'Evening in the Parlor',
file: '/audios/radios/classic/Evening in the Parlor.mp3',
},
{
title: 'Glass Atrium',
file: '/audios/radios/classic/Glass Atrium.mp3',
},
{
title: 'Moonlight On The Drafting Table',
file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
},
{
title: 'Sunlit Garden Reverie',
file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
},
{
title: 'Sunlit Waltz in Pastel Hues',
file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
},
]
// Shuffle array helper
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
export function PascalRadio() {
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()
const soundRef = useRef<Howl | null>(null)
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const currentTrack = shuffledPlaylist[currentTrackIndex]!
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
// Keep a ref so the track-init effect can read current volume/muted/isRadioPlaying
// without those values being part of its dependency array (which would restart the song).
const effectiveVolumeRef = useRef(effectiveVolume)
const mutedRef = useRef(muted)
const isPlayingRef = useRef(isRadioPlaying)
effectiveVolumeRef.current = effectiveVolume
mutedRef.current = muted
isPlayingRef.current = isRadioPlaying
const handleNext = useCallback(() => {
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
const handlePrevious = useCallback(() => {
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
// Initialize Howler only when the track changes — not on volume/mute/play-state changes.
// Volume and mute are handled by the separate effect below.
useEffect(() => {
if (soundRef.current) {
soundRef.current.unload()
}
const wasPlaying = isPlayingRef.current
soundRef.current = new Howl({
src: [currentTrack.file],
volume: mutedRef.current ? 0 : effectiveVolumeRef.current,
onend: handleNext,
})
if (wasPlaying && !mutedRef.current) {
soundRef.current?.play()
}
return () => {
soundRef.current?.unload()
}
}, [handleNext, currentTrack.file])
// Update volume when settings change
useEffect(() => {
if (soundRef.current) {
soundRef.current.volume(muted ? 0 : effectiveVolume)
// Pause if muted, resume if unmuted and was playing
if (muted && isRadioPlaying) {
soundRef.current.pause()
} else if (!muted && isRadioPlaying && !soundRef.current.playing()) {
soundRef.current.play()
} else if (!isRadioPlaying && soundRef.current.playing()) {
soundRef.current.pause()
}
}
}, [effectiveVolume, muted, isRadioPlaying])
const handlePlayPause = () => {
if (!soundRef.current || muted) return
if (isRadioPlaying) {
soundRef.current.pause()
} else {
soundRef.current.play()
}
setRadioPlaying(!isRadioPlaying)
}
const handleVolumeChange = (value: number[]) => {
useAudio.setState({ radioVolume: value[0] })
}
// Handle click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<motion.div
className={cn(
'flex flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md',
!isOpen && 'cursor-pointer transition-colors hover:bg-accent/90',
)}
layout
onClick={() => {
if (!isOpen) setIsOpen(true)
}}
ref={containerRef}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="flex items-center justify-between gap-2 px-3 py-2 font-medium text-sm">
<div className="flex items-center gap-2">
<Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
<span className="hidden whitespace-nowrap sm:inline">Radio Pascal</span>
</div>
<div className="flex items-center gap-2">
<div
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
className="cursor-pointer rounded-sm bg-accent/30 p-1 transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
onClick={(e) => {
e.stopPropagation()
handlePlayPause()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
handlePlayPause()
}
}}
role="button"
tabIndex={0}
>
{isRadioPlaying ? (
<Volume2 className="h-3.5 w-3.5" />
) : (
<VolumeX className="h-3.5 w-3.5" />
)}
</div>
<button
aria-label="Radio Settings"
className={cn(
'cursor-pointer rounded-sm p-1 transition-all hover:bg-accent hover:text-accent-foreground',
isOpen && 'bg-accent text-accent-foreground',
)}
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
initial={{ opacity: 0, height: 0 }}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="w-[16rem] space-y-3 px-3 pb-3">
<div className="mb-3 h-px w-full bg-border/50" />
{/* Current song info with prev/next */}
<div>
<p className="mb-2 text-muted-foreground text-xs">Now Playing</p>
<div className="flex items-center justify-between gap-2">
<button
aria-label="Previous"
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
onClick={handlePrevious}
>
<SkipBack className="h-4 w-4" />
</button>
<p
className="flex-1 truncate text-center font-medium text-sm"
title={currentTrack.title}
>
{currentTrack.title}
</p>
<button
aria-label="Next"
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
onClick={handleNext}
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Slider
aria-label="Radio Volume"
className="flex-1"
max={100}
onValueChange={handleVolumeChange}
step={1}
value={[radioVolume]}
/>
<span className="w-8 shrink-0 text-right text-muted-foreground text-xs">
{radioVolume}%
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
@@ -1,16 +0,0 @@
'use client'
import { Eye } from 'lucide-react'
import useEditor from '../store/use-editor'
export function PreviewButton() {
return (
<button
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
onClick={() => useEditor.getState().setPreviewMode(true)}
>
<Eye className="h-4 w-4 shrink-0" />
<span className="hidden whitespace-nowrap sm:inline">Preview</span>
</button>
)
}
@@ -75,7 +75,9 @@ const CeilingSelectionAffordance = ({
ceiling: CeilingNode ceiling: CeilingNode
levelId: string levelId: string
}) => { }) => {
const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null) const [levelObject, setLevelObject] = useState<Object3D | null>(
() => sceneRegistry.nodes.get(levelId) ?? null,
)
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon]) const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
@@ -110,11 +112,7 @@ const CeilingSelectionAffordance = ({
return createPortal( return createPortal(
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}> <group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
{corners.map((corner, index) => ( {corners.map((corner, index) => (
<CornerBracket <CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
ceiling={ceiling}
corner={corner}
key={`${ceiling.id}-corner-${index}`}
/>
))} ))}
</group>, </group>,
levelObject, levelObject,
@@ -210,11 +208,7 @@ const BracketLeg = ({
] ]
return ( return (
<mesh <mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
onClick={onClick}
position={position}
rotation={[0, angle, 0]}
>
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} /> <boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent /> <meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
</mesh> </mesh>
@@ -234,7 +228,11 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1]) const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1]) const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1]) const cornerStrength =
1 -
Math.abs(
incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
)
return { return {
corner, corner,
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
* Imperatively toggles the Three.js visibility of roof objects based on the * Imperatively toggles the Three.js visibility of roof objects based on the
* editor selection — without causing React re-renders in RoofRenderer. * editor selection — without causing React re-renders in RoofRenderer.
* *
* When a roof-segment is selected: * When a roof (or one of its segments) is selected:
* - merged-roof mesh is hidden * - merged-roof mesh is hidden
* - segments-wrapper group is shown (individual segments visible for editing) * - segments-wrapper group is shown (individual segments visible for editing)
* - all children are marked dirty so RoofSystem rebuilds their geometry * - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -68,7 +68,7 @@ export const StairEditSystem = () => {
const segmentsWrapper = group.getObjectByName('segments-wrapper') const segmentsWrapper = group.getObjectByName('segments-wrapper')
const isActive = activeStairIds.has(stairId) const isActive = activeStairIds.has(stairId)
if (mergedMesh) mergedMesh.visible = !isActive && !isCurved if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
if (stairNode?.children?.length) { if (stairNode?.children?.length) {
View File
@@ -1,13 +1,19 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core' import {
type AnyNodeId,
type CeilingNode,
emitter,
type GridEvent,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
function snap(value: number) { function snap(value: number) {
return Math.round(value * 2) / 2 return Math.round(value * 2) / 2
@@ -202,6 +208,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
transparent transparent
/> />
</mesh> </mesh>
{/* @ts-ignore */}
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}> <line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent /> <lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
</line> </line>
@@ -83,11 +83,10 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
? event.localPosition[2] ? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep) : snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint = const offsetFromMidpoint = -(
-( (localX - chord.midpoint.x) * chord.normal.x +
(localX - chord.midpoint.x) * chord.normal.x + (localZ - chord.midpoint.y) * chord.normal.y
(localZ - chord.midpoint.y) * chord.normal.y )
)
const snappedOffset = shiftPressedRef.current const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint ? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep) : snapScalarToGrid(offsetFromMidpoint, snapStep)
@@ -297,7 +297,7 @@ export const FenceTool: React.FC = () => {
return ( return (
<group> <group>
<CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} /> <CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}> <mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry /> <shapeGeometry />
<meshBasicMaterial <meshBasicMaterial
@@ -338,7 +338,7 @@ function DraftMeasurementLabel({
}) { }) {
return ( return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}> <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md"> <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label} {label}
</div> </div>
</Html> </Html>
@@ -131,10 +131,12 @@ function getLinkedFenceSnapshots(args: {
} }
if ( if (
!samePoint(node.start, originalStart) && !(
!samePoint(node.start, originalEnd) && samePoint(node.start, originalStart) ||
!samePoint(node.end, originalStart) && samePoint(node.start, originalEnd) ||
!samePoint(node.end, originalEnd) samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) { ) {
continue continue
} }
@@ -303,8 +305,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
} }
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged = const hasChanged = !(
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd) samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) { if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true wasCommitted = true
@@ -406,7 +409,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
> >
<div className="translate-y-10"> <div className="translate-y-10">
<div <div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${ className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100' ? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80' : 'border-border/70 bg-background/90 text-foreground/80'
@@ -430,7 +433,7 @@ function EndpointAngleLabel({
}) { }) {
return ( return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}> <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md"> <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label} {label}
</div> </div>
</Html> </Html>
@@ -17,8 +17,8 @@ import type * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from './fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { snapFenceDraftPoint } from './fence-drafting'
function samePoint(a: [number, number], b: [number, number]) { function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1] return a[0] === b[0] && a[1] === b[1]
@@ -50,10 +50,12 @@ function getLinkedFenceSnapshots(args: {
} }
if ( if (
!samePoint(node.start, originalStart) && !(
!samePoint(node.start, originalEnd) && samePoint(node.start, originalStart) ||
!samePoint(node.end, originalStart) && samePoint(node.start, originalEnd) ||
!samePoint(node.end, originalEnd) samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) { ) {
continue continue
} }
@@ -164,7 +166,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
} }
} }
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => { const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes( useScene.getState().updateNodes(
updates.map((entry) => ({ updates.map((entry) => ({
id: entry.id as AnyNodeId, id: entry.id as AnyNodeId,
@@ -275,13 +279,13 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
return () => { return () => {
if (!wasCommitted) { if (wasCommitted) {
clearPreviewState()
} else {
useLiveTransforms.getState().clear(nodeId) useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) { for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id) useLiveTransforms.getState().clear(linkedFence.id)
} }
} else {
clearPreviewState()
} }
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
@@ -90,9 +90,7 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
return <>{cursor}</> return <>{cursor}</>
} }
export const MoveTool: React.FC<{ export const MoveTool: React.FC = () => {
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onSpawnMoved }) => {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null if (!movingNode) return null
@@ -100,15 +98,14 @@ export const MoveTool: React.FC<{
return <MoveBuildingContent node={movingNode as BuildingNode} /> return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} /> if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} /> if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} /> if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} /> if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} /> if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} /> if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} /> return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'spawn') if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} /> return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} /> return <MoveItemContent movingNode={movingNode as ItemNode} />
@@ -10,9 +10,7 @@ function positiveModulo(value: number, divisor: number): number {
} }
/** /**
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines. * Snaps a position to the active grid step, aligning item edges to grid lines.
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
*/ */
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number { export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
const halfDim = dimension / 2 const halfDim = dimension / 2
@@ -21,7 +19,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
} }
/** /**
* Snap a value to 0.5 increments (used for wall-local positions). * Snap a value to the active grid step (used for wall-local positions).
*/ */
export function snapToHalf(value: number, step = getGridSnapStep()): number { export function snapToHalf(value: number, step = getGridSnapStep()): number {
return Math.round(value / step) * step return Math.round(value / step) * step
@@ -506,21 +506,22 @@ export const itemSurfaceStrategy = {
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
// Counter-rotate so the draft's world Y rotation stays continuous when
// the user drags onto a rotated surface item. The cursor wireframe
// already shows the user's intended world rotation; we just need to
// store the right local value relative to the new parent.
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
return { return {
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id }, stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
nodeUpdate: { nodeUpdate: {
position: [x, y, z], position: [x, y, z],
parentId: surfaceItem.id, parentId: surfaceItem.id,
rotation: [ rotation: [draftRotation[0], localRotationY, draftRotation[2]],
(ctx.draftItem?.rotation ?? [0, 0, 0])[0],
(() => {
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
return ctx.currentCursorRotationY - surfaceWorldY
})(),
(ctx.draftItem?.rotation ?? [0, 0, 0])[2],
] as [number, number, number],
}, },
cursorRotationY: ctx.currentCursorRotationY, cursorRotationY: ctx.currentCursorRotationY,
gridPosition: [x, y, z], gridPosition: [x, y, z],
@@ -130,7 +130,6 @@ export function useDraftNode(): DraftNodeHandle {
useScene.getState().updateNode(draft.id, { useScene.getState().updateNode(draft.id, {
position: updateProps.position ?? draft.position, position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation, rotation: updateProps.rotation ?? draft.rotation,
scale: updateProps.scale ?? draft.scale,
side: updateProps.side ?? draft.side, side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata), metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string, parentId: parentId as string,
@@ -20,15 +20,12 @@ import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
Box3,
BufferGeometry, BufferGeometry,
Euler, Euler,
Float32BufferAttribute, Float32BufferAttribute,
type Group, type Group,
type LineSegments, type LineSegments,
Matrix4,
type Mesh, type Mesh,
type Object3D,
PlaneGeometry, PlaneGeometry,
Quaternion, Quaternion,
Vector3, Vector3,
@@ -117,79 +114,6 @@ function expandBoundsToGrid(
} }
} }
function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
if (!object) return null
object.updateWorldMatrix(true, true)
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
const localMatrix = new Matrix4()
const localBounds = new Box3()
const scratchBounds = new Box3()
const hasBounds = { current: false }
const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
const expandBounds = (child: Object3D) => {
if (child !== object && registeredNodeObjects.has(child)) {
return
}
const mesh = child as Object3D & {
isMesh?: boolean
name?: string
geometry?: {
boundingBox: Box3 | null
computeBoundingBox?: () => void
}
}
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
mesh.geometry.computeBoundingBox()
}
if (mesh.geometry.boundingBox) {
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
if (!hasBounds.current) {
localBounds.copy(scratchBounds)
hasBounds.current = true
} else {
localBounds.union(scratchBounds)
}
}
}
}
for (const grandchild of child.children) {
expandBounds(grandchild)
}
}
for (const child of object.children) {
expandBounds(child)
}
if (!hasBounds.current) return null
const size = new Vector3()
const center = new Vector3()
localBounds.getSize(size)
localBounds.getCenter(center)
if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
return null
}
return {
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
dimensions: [size.x, size.y, size.z],
center: [center.x, center.y, center.z],
}
}
function getFallbackPreviewBounds( function getFallbackPreviewBounds(
item: import('@pascal-app/core').ItemNode | null, item: import('@pascal-app/core').ItemNode | null,
asset: AssetInput, asset: AssetInput,
@@ -366,7 +290,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
) )
const shiftFreeRef = useRef(false) const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null) const previewBoundsSignatureRef = useRef<string | null>(null)
const meshPreviewAppliedRef = useRef(false)
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null) const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change // Store config callbacks in refs to avoid re-running effect when they change
@@ -503,7 +426,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useEffect(() => { useEffect(() => {
if (!asset) return if (!asset) return
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
meshPreviewAppliedRef.current = false
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
@@ -1264,21 +1186,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
window.addEventListener('contextmenu', onContextMenu) window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ---- // ---- Bounding box geometry ----
// Always derive the wireframe from `asset.dimensions × scale` rather than
// the rendered mesh bounds. Asset dimensions describe the item's footprint
// (e.g. only the trunk for a palm tree), while the mesh bbox would include
// foliage or other visual overhang the snap logic intentionally ignores.
const draft = draftNode.current const draft = draftNode.current
const fallbackBounds = expandBoundsToGrid( const previewBounds = expandBoundsToGrid(
getFallbackPreviewBounds(draft, asset, asset.attachTo), getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo, asset.attachTo,
gridSnapStep, gridSnapStep,
) )
const previewBounds = draft
? expandBoundsToGrid(
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo,
gridSnapStep,
)
: fallbackBounds
updatePreviewGeometry(previewBounds) updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds) updateDimensionGuides(previewBounds)
@@ -1324,7 +1242,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return () => { return () => {
tearingDown = true tearingDown = true
meshPreviewAppliedRef.current = false
unsubDraftWatch() unsubDraftWatch()
// Clear live transform for any remaining draft // Clear live transform for any remaining draft
if (draftNode.current) { if (draftNode.current) {
@@ -1358,17 +1275,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useEffect(() => { useEffect(() => {
if (!asset) return if (!asset) return
const draft = draftNode.current const draft = draftNode.current
const fallbackBounds = expandBoundsToGrid( const previewBounds = expandBoundsToGrid(
getFallbackPreviewBounds(draft, asset, asset.attachTo), getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo, asset.attachTo,
gridSnapStep, gridSnapStep,
) )
const meshBounds = draft
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
: null
const previewBounds = meshBounds
? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
: fallbackBounds
updatePreviewGeometry(previewBounds) updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds) updateDimensionGuides(previewBounds)
}, [gridSnapStep, asset, draftNode]) }, [gridSnapStep, asset, draftNode])
@@ -1388,19 +1299,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!draftNode.current) return if (!draftNode.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id) const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return if (!mesh) return
if (!meshPreviewAppliedRef.current) {
const previewBounds = getPreviewBoundsFromObject(mesh)
if (previewBounds) {
const expandedBounds = expandBoundsToGrid(
previewBounds,
asset.attachTo,
useEditor.getState().gridSnapStep,
)
updatePreviewGeometry(expandedBounds)
updateDimensionGuides(expandedBounds)
meshPreviewAppliedRef.current = true
}
}
// Hide wall/ceiling-attached items when between surfaces (only cursor visible) // Hide wall/ceiling-attached items when between surfaces (only cursor visible)
if (asset.attachTo && placementState.current.surface === 'floor') { if (asset.attachTo && placementState.current.surface === 'floor') {
@@ -1490,22 +1388,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const measurementContent = ( const measurementContent = (
<> <>
<lineSegments <lineSegments
layers={EDITOR_LAYER}
geometry={initialWidthGuideGeometry} geometry={initialWidthGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial} material={measurementMaterial}
ref={measurementWidthRef} ref={measurementWidthRef}
renderOrder={998} renderOrder={998}
/> />
<lineSegments <lineSegments
layers={EDITOR_LAYER}
geometry={initialDepthGuideGeometry} geometry={initialDepthGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial} material={measurementMaterial}
ref={measurementDepthRef} ref={measurementDepthRef}
renderOrder={998} renderOrder={998}
/> />
<lineSegments <lineSegments
layers={EDITOR_LAYER}
geometry={initialHeightGuideGeometry} geometry={initialHeightGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial} material={measurementMaterial}
ref={measurementHeightRef} ref={measurementHeightRef}
renderOrder={998} renderOrder={998}
@@ -158,7 +158,9 @@ export const MoveRoofTool: React.FC<{
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => { const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) { if (buildingObj) {
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1])) const worldPoint = buildingObj.localToWorld(
new THREE.Vector3(localPoint[0], y, localPoint[1]),
)
return [worldPoint.x, worldPoint.y, worldPoint.z] return [worldPoint.x, worldPoint.y, worldPoint.z]
} }
@@ -211,7 +213,10 @@ export const MoveRoofTool: React.FC<{
}) })
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y) const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) { if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -192,7 +192,17 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
const result: string[] = [] const result: string[] = []
if (phase === 'structure' && structureLayer === 'elements') { if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node || node.type !== 'zone') continue
const zone = node as ZoneNode
if (polygonIntersectsBounds(zone.polygon, bounds)) {
result.push(zone.id)
}
}
} else {
// structure (elements) and furnish: collect all node types
for (const childId of levelNode.children) { for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId] const node = nodes[childId as AnyNodeId]
if (!node) continue if (!node) continue
@@ -240,22 +250,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
if (objectBoundsIntersectsBounds(node.id, bounds)) { if (objectBoundsIntersectsBounds(node.id, bounds)) {
result.push(node.id) result.push(node.id)
} }
} } else if (node.type === 'item') {
}
} else if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node || node.type !== 'zone') continue
const zone = node as ZoneNode
if (polygonIntersectsBounds(zone.polygon, bounds)) {
result.push(zone.id)
}
}
} else if (phase === 'furnish') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node) continue
if (node.type === 'item') {
const item = node as ItemNode const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') continue if (item.asset.category === 'door' || item.asset.category === 'window') continue
const xz = getNodeWorldXZ(item.id) const xz = getNodeWorldXZ(item.id)
@@ -145,7 +145,7 @@ export function getSegmentAngleReferenceAtPoint(
} }
const projected = getProjectedPointOnSegment(point, segment) const projected = getProjectedPointOnSegment(point, segment)
if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) { if (!(projected && pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE))) {
return null return null
} }
@@ -45,6 +45,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
door: DoorTool, door: DoorTool,
item: ItemTool, item: ItemTool,
zone: ZoneTool, zone: ZoneTool,
spawn: SpawnTool,
window: WindowTool, window: WindowTool,
}, },
furnish: { furnish: {
@@ -63,10 +64,9 @@ export const ToolManager: React.FC = () => {
const curvingFence = useEditor((state) => state.curvingFence) const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole) const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedLevelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId)
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const setSelection = useViewer((state) => state.setSelection) const buildingId = useViewer((state) => state.selection.buildingId)
const activeLevelId = useViewer((state) => state.selection.levelId)
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
// Building transform for the local group — all building-relative tools live inside this group // Building transform for the local group — all building-relative tools live inside this group
@@ -128,13 +128,13 @@ export const ToolManager: React.FC = () => {
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => { const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
} }
return ( return (
<> <>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{/* World-space tools: site boundary and building movement operate in world coordinates */} {/* World-space tools: site boundary and building movement operate in world coordinates */}
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />} {movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
{/* Building-local group: all other tools are relative to the selected building. {/* Building-local group: all other tools are relative to the selected building.
@@ -162,13 +162,13 @@ export const ToolManager: React.FC = () => {
{movingNode && movingNode.type !== 'building' && ( {movingNode && movingNode.type !== 'building' && (
<MoveTool onSpawnMoved={handlePlacedNodeSelected} /> <MoveTool onSpawnMoved={handlePlacedNodeSelected} />
)} )}
{!movingNode && showBuildTool && tool === 'spawn' && ( {!movingNode && BuildToolComponent && tool === 'spawn' ? (
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} /> <SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)} ) : !movingNode && showBuildTool && tool === 'column' ? (
{!movingNode && showBuildTool && tool === 'column' && ( <ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} /> ) : !movingNode && BuildToolComponent && tool !== 'column' ? (
)} <BuildToolComponent />
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />} ) : null}
</group> </group>
</> </>
) )
@@ -81,15 +81,17 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
? event.localPosition[2] ? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep) : snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint = const offsetFromMidpoint = -(
-( (localX - chord.midpoint.x) * chord.normal.x +
(localX - chord.midpoint.x) * chord.normal.x + (localZ - chord.midpoint.y) * chord.normal.y
(localZ - chord.midpoint.y) * chord.normal.y )
)
const snappedOffset = shiftPressedRef.current const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint ? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep) : snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset))) const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if ( if (
previousCurveOffsetRef.current !== null && previousCurveOffsetRef.current !== null &&
@@ -112,10 +112,12 @@ function getLinkedWallSnapshots(args: {
} }
if ( if (
!samePoint(node.start, originalStart) && !(
!samePoint(node.start, originalEnd) && samePoint(node.start, originalStart) ||
!samePoint(node.end, originalStart) && samePoint(node.start, originalEnd) ||
!samePoint(node.end, originalEnd) samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) { ) {
continue continue
} }
@@ -286,8 +288,9 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
} }
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged = const hasChanged = !(
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd) samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) { if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true wasCommitted = true
@@ -391,7 +394,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
> >
<div className="translate-y-10"> <div className="translate-y-10">
<div <div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${ className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100' ? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground' : 'border-border bg-background/95 text-muted-foreground'
@@ -415,7 +418,7 @@ function EndpointAngleLabel({
}) { }) {
return ( return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}> <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md"> <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label} {label}
</div> </div>
</Html> </Html>
@@ -63,10 +63,12 @@ function getLinkedWallSnapshots(args: {
} }
if ( if (
!samePoint(node.start, originalStart) && !(
!samePoint(node.start, originalEnd) && samePoint(node.start, originalStart) ||
!samePoint(node.end, originalStart) && samePoint(node.start, originalEnd) ||
!samePoint(node.end, originalEnd) samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) { ) {
continue continue
} }
View File
+3 -3
View File
@@ -156,6 +156,8 @@ export const WallTool: React.FC = () => {
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const cursorRef = useRef<Group>(null) const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!) const wallPreviewRef = useRef<Mesh>(null!)
// All positions are building-local: this tool is inside the ToolManager building group,
// so local coords are used for both data and visual positioning.
const startingPoint = useRef(new Vector3(0, 0, 0)) const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0) const buildingState = useRef(0)
@@ -166,8 +168,6 @@ export const WallTool: React.FC = () => {
let gridPosition: WallPlanPoint = [0, 0] let gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null let previousWallEnd: [number, number] | null = null
// All positions are building-local: this tool is inside the ToolManager building group,
// so local coords are used for both data and visual positioning.
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && wallPreviewRef.current)) return if (!(cursorRef.current && wallPreviewRef.current)) return
@@ -324,7 +324,7 @@ function DraftMeasurementLabel({
}) { }) {
return ( return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}> <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md"> <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label} {label}
</div> </div>
</Html> </Html>
+20 -5
View File
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { EDITOR_LAYER } from './../../../lib/constants' import { EDITOR_LAYER } from './../../../lib/constants'
import { sfxEmitter } from './../../../lib/sfx-bus'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
@@ -67,6 +68,9 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
// Select the newly created zone // Select the newly created zone
useViewer.getState().setSelection({ zoneId: zone.id }) useViewer.getState().setSelection({ zoneId: zone.id })
// Play structure build sound
sfxEmitter.emit('sfx:structure-build')
} }
type PreviewState = { type PreviewState = {
@@ -86,6 +90,7 @@ export const ZoneTool: React.FC = () => {
const mainLineRef = useRef<Line>(null!) const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!) const closingLineRef = useRef<Line>(null!)
const pointsRef = useRef<Array<[number, number]>>([]) const pointsRef = useRef<Array<[number, number]>>([])
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const levelYRef = useRef(0) // Track current level Y position const levelYRef = useRef(0) // Track current level Y position
const currentLevelId = useViewer((state) => state.selection.levelId) const currentLevelId = useViewer((state) => state.selection.levelId)
const setTool = useEditor((state) => state.setTool) const setTool = useEditor((state) => state.setTool)
@@ -181,12 +186,22 @@ export const ZoneTool: React.FC = () => {
// If we have points, snap to axis from last point // If we have points, snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1] const lastPoint = pointsRef.current[pointsRef.current.length - 1]
if (lastPoint) { const displayPoint = lastPoint
const snapped = calculateSnapPoint(lastPoint, cursorPosition) ? calculateSnapPoint(lastPoint, cursorPosition)
cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1]) : cursorPosition
} else {
cursorRef.current.position.set(gridX, event.localPosition[1], gridZ) // Play snap sound when the snapped position changes during drawing
if (
pointsRef.current.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
displayPoint[1] !== previousSnappedPointRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
} }
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
updatePreview() updatePreview()
} }
View File
+7 -1
View File
@@ -104,7 +104,9 @@ export function ControlModes() {
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const setStructureLayer = useEditor((state) => state.setStructureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool) const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection) const primeMaterialPaintFromSelection = useEditor(
(state) => state.primeMaterialPaintFromSelection,
)
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
// Only subscribe to the primitive `level` number — when walls are added to // Only subscribe to the primitive `level` number — when walls are added to
@@ -148,6 +150,8 @@ export function ControlModes() {
// setPhase('site') calls viewer.resetSelection() which clears levelId, // setPhase('site') calls viewer.resetSelection() which clears levelId,
// breaking the 2D floorplan (it needs a level to render the SVG). // breaking the 2D floorplan (it needs a level to render the SVG).
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null }) useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
// Clear object selection so the polygon editor handles receive pointer events
useViewer.getState().setSelection({ selectedIds: [] })
} }
return return
} }
@@ -188,6 +192,8 @@ export function ControlModes() {
} else { } else {
setPhase('furnish') setPhase('furnish')
setMode('build') setMode('build')
// Auto-switch sidebar to the items panel so the user can pick furniture
useEditor.getState().setActiveSidebarPanel('items')
} }
} else if (id === 'zone') { } else if (id === 'zone') {
if (getIsActive('zone')) { if (getIsActive('zone')) {
@@ -1,9 +1,4 @@
'use client' import type { CatalogCategory } from './../../../store/use-editor'
import NextImage from 'next/image'
import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { ActionButton } from './action-button'
export type FurnishToolConfig = { export type FurnishToolConfig = {
id: 'item' id: 'item'
@@ -12,91 +7,10 @@ export type FurnishToolConfig = {
catalogCategory: CatalogCategory catalogCategory: CatalogCategory
} }
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
export const furnishTools: FurnishToolConfig[] = [ export const furnishTools: FurnishToolConfig[] = [
{ { id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' },
id: 'item', { id: 'item', iconSrc: '/icons/appliance.png', label: 'Appliance', catalogCategory: 'appliance' },
iconSrc: '/icons/couch.png', { id: 'item', iconSrc: '/icons/kitchen.png', label: 'Kitchen', catalogCategory: 'kitchen' },
label: 'Furniture', { id: 'item', iconSrc: '/icons/bathroom.png', label: 'Bathroom', catalogCategory: 'bathroom' },
catalogCategory: 'furniture', { id: 'item', iconSrc: '/icons/tree.png', label: 'Outdoor', catalogCategory: 'outdoor' },
},
{
id: 'item',
iconSrc: '/icons/appliance.png',
label: 'Appliance',
catalogCategory: 'appliance',
},
{
id: 'item',
iconSrc: '/icons/kitchen.png',
label: 'Kitchen',
catalogCategory: 'kitchen',
},
{
id: 'item',
iconSrc: '/icons/bathroom.png',
label: 'Bathroom',
catalogCategory: 'bathroom',
},
{
id: 'item',
iconSrc: '/icons/tree.png',
label: 'Outdoor',
catalogCategory: 'outdoor',
},
] ]
export function FurnishTools() {
const mode = useEditor((state) => state.mode)
const activeTool = useEditor((state) => state.tool)
const setActiveTool = useEditor((state) => state.setTool)
const setMode = useEditor((state) => state.setMode)
const catalogCategory = useEditor((state) => state.catalogCategory)
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
const hasActiveTool = furnishTools.some(
(tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,
)
return (
<div className="flex items-center gap-1.5 px-1">
{furnishTools.map((tool, index) => {
// For item tools with catalog category, check both tool and category match
const isActive =
mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory
return (
<ActionButton
className={cn(
'rounded-lg duration-300',
isActive
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
)}
key={`${tool.id}-${tool.catalogCategory ?? index}`}
label={tool.label}
onClick={() => {
if (!isActive) {
setCatalogCategory(tool.catalogCategory)
setActiveTool('item')
if (mode !== 'build') {
setMode('build')
}
}
}}
size="icon"
variant="ghost"
>
<NextImage
alt={tool.label}
className="size-full object-contain"
height={28}
src={tool.iconSrc}
width={28}
/>
</ActionButton>
)
})}
</div>
)
}
@@ -1,22 +1,25 @@
'use client' 'use client'
import { useScene } from '@pascal-app/core' import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { useViewer } from '@pascal-app/viewer'
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import { useIsMobile } from './../../../hooks/use-mobile'
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { MaterialPicker } from './../../../components/ui/controls/material-picker' import { MaterialPicker } from './../../../components/ui/controls/material-picker'
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { useIsMobile } from './../../../hooks/use-mobile'
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint' import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
import { cn } from './../../../lib/utils' import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { ItemCatalog } from '../item-catalog/item-catalog'
import { CameraActions } from './camera-actions' import { CameraActions } from './camera-actions'
import { ControlModes } from './control-modes' import { ControlModes } from './control-modes'
import { FurnishTools } from './furnish-tools'
import { StructureTools } from './structure-tools' import { StructureTools } from './structure-tools'
import { ViewToggles } from './view-toggles' import { GridSnapControl, SecondaryToggles } from './view-toggles'
// Mobile bottom offset matches the viewer's overlap behind the sheet's
// rounded corners (SHEET_OVERLAP_PX in editor-layout-mobile) so the menu sits
// just above that strip instead of inside it.
const MOBILE_BOTTOM_OFFSET = 24
function PaintMaterialTray() { function PaintMaterialTray() {
const activePaintMaterial = useEditor((state) => state.activePaintMaterial) const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
@@ -84,84 +87,16 @@ export function ActionMenu({ className }: { className?: string }) {
<TooltipProvider> <TooltipProvider>
<motion.div <motion.div
className={cn( className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2', 'left-1/2 z-50 -translate-x-1/2',
isMobile ? 'absolute origin-bottom scale-90' : 'fixed bottom-6',
'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md', 'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
'transition-colors duration-200 ease-out', 'transition-colors duration-200 ease-out',
className, className,
)} )}
layout layout
style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
transition={transition} transition={transition}
> >
{/* Item Catalog Row - Only show when in build mode with item tool */}
<AnimatePresence>
{mode === 'build' && tool === 'item' && catalogCategory && (
<motion.div
animate={{
opacity: 1,
maxHeight: 160,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
className={cn('overflow-hidden border-border border-b px-2 py-2')}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<ItemCatalog category={catalogCategory} key={catalogCategory} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{phase === 'furnish' && mode === 'build' && (
<motion.div
animate={{
opacity: 1,
maxHeight: 80,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
className={cn(
'overflow-hidden border-border',
'max-h-20 border-b px-2 py-2 opacity-100',
)}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<div className="mx-auto w-max">
<FurnishTools />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Structure Tools Row - Animated */} {/* Structure Tools Row - Animated */}
<AnimatePresence> <AnimatePresence>
{phase === 'structure' && mode === 'build' && ( {phase === 'structure' && mode === 'build' && (
@@ -228,14 +163,28 @@ export function ActionMenu({ className }: { className?: string }) {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* Control Mode Row - Always visible, centered */} {isMobile ? (
<div className="flex items-center justify-center gap-1 px-2 py-1.5"> <div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
<ControlModes /> {/* Row 1: control modes only */}
<div className="mx-1 h-5 w-px bg-border" /> <div className="flex items-center justify-center gap-1">
<ViewToggles /> <ControlModes />
<div className="mx-1 h-5 w-px bg-border" /> </div>
<CameraActions /> {/* Row 2: grid snap + secondary toggles (orbit + top view hidden) */}
</div> <div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1">
<GridSnapControl />
<SecondaryToggles />
</div>
</div>
) : (
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes />
<div className="mx-1 h-5 w-px bg-border" />
<GridSnapControl />
<SecondaryToggles />
<div className="mx-1 h-5 w-px bg-border" />
<CameraActions />
</div>
)}
</motion.div> </motion.div>
</TooltipProvider> </TooltipProvider>
) )
+19 -31
View File
@@ -1,5 +1,6 @@
'use client' 'use client'
import { Icon } from '@iconify/react'
import { import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
@@ -258,7 +259,7 @@ function GuidesControl() {
<PopoverContent <PopoverContent
align="center" align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl" className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
side="top" side="top"
sideOffset={14} sideOffset={14}
> >
@@ -353,9 +354,9 @@ function GuidesControl() {
) )
} }
// ── Grid snap ────────────────────────────────────────────────────────────── // ── Grid snap toggle ────────────────────────────────────────────────────────
export function GridSnapControl() { function GridSnapControl() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const gridSnapStep = useEditor((state) => state.gridSnapStep) const gridSnapStep = useEditor((state) => state.gridSnapStep)
const setGridSnapStep = useEditor((state) => state.setGridSnapStep) const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
@@ -374,20 +375,7 @@ export function GridSnapControl() {
)} )}
type="button" type="button"
> >
<svg <Icon height={16} icon="lucide:grid-2x2" width={16} />
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3h7v7H3V3zm11 0h7v7h-7V3zm0 11h7v7h-7v-7zm-11 0h7v7H3v-7z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="mt-1 font-medium text-[9px] leading-none"> <span className="mt-1 font-medium text-[9px] leading-none">
{formatGridSnapStep(gridSnapStep)} {formatGridSnapStep(gridSnapStep)}
</span> </span>
@@ -510,7 +498,7 @@ function ScansControl() {
<PopoverContent <PopoverContent
align="center" align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl" className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
side="top" side="top"
sideOffset={14} sideOffset={14}
> >
@@ -605,6 +593,8 @@ function ScansControl() {
) )
} }
// ── Reference floor control ────────────────────────────────────────────────────────────────────
function ReferenceFloorControl() { function ReferenceFloorControl() {
const showReferenceFloor = useEditor((state) => state.showReferenceFloor) const showReferenceFloor = useEditor((state) => state.showReferenceFloor)
const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor) const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor)
@@ -694,7 +684,11 @@ function ReferenceFloorControl() {
onClick={toggleReferenceFloor} onClick={toggleReferenceFloor}
type="button" type="button"
> >
{showReferenceFloor ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />} {showReferenceFloor ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
</button> </button>
</div> </div>
@@ -728,9 +722,7 @@ function ReferenceFloorControl() {
)} )}
/> />
<span className="min-w-0 flex-1 truncate">{levelName}</span> <span className="min-w-0 flex-1 truncate">{levelName}</span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">{index + 1} below</span>
{index + 1} below
</span>
</button> </button>
) )
})} })}
@@ -757,24 +749,20 @@ function ReferenceFloorControl() {
) )
} }
// ── Main ViewToggles ──────────────────────────────────────────────────────── // ── Exports ─────────────────────────────────────────────────────────────────
export function ViewToggles() { export { GridSnapControl }
export function SecondaryToggles() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Scans (toggle + dropdown) */}
<ScansControl /> <ScansControl />
{/* Guides (toggle + dropdown) */}
<GuidesControl /> <GuidesControl />
<ReferenceFloorControl />
</div> </div>
) )
} }
// Secondary toggles for mobile (grid snap + scans + guides) export function ViewToggles() {
export function SecondaryToggles() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<GridSnapControl /> <GridSnapControl />
@@ -35,8 +35,8 @@ import {
Video, Video,
} from 'lucide-react' } from 'lucide-react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { runRedo, runUndo } from '../../../lib/history' import { runRedo, runUndo } from '../../../lib/history'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { useCommandRegistry } from '../../../store/use-command-registry' import { useCommandRegistry } from '../../../store/use-command-registry'
import type { StructureTool } from '../../../store/use-editor' import type { StructureTool } from '../../../store/use-editor'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -294,12 +294,14 @@ export function EditorCommands() {
}, },
{ {
id: 'editor.viewer.camera-snapshot', id: 'editor.viewer.camera-snapshot',
label: 'Camera Snapshot', label: 'Take Snapshot',
group: 'Viewer Controls', group: 'Viewer Controls',
icon: <Camera className="h-4 w-4" />, icon: <Camera className="h-4 w-4" />,
keywords: ['camera', 'snapshot', 'capture', 'save', 'view', 'bookmark'], keywords: ['camera', 'snapshot', 'capture', 'save', 'view', 'bookmark'],
navigate: true, execute: () => {
execute: () => navigateTo('camera-view'), setOpen(false)
useEditor.getState().setCaptureMode(true)
},
}, },
// ── View ───────────────────────────────────────────────────────────── // ── View ─────────────────────────────────────────────────────────────
+4 -255
View File
@@ -27,15 +27,13 @@ interface CommandPaletteStore {
setInputValue: (value: string) => void setInputValue: (value: string) => void
navigateTo: (page: string) => void navigateTo: (page: string) => void
goBack: () => void goBack: () => void
cameraScope: { nodeId: string; label: string } | null
setCameraScope: (scope: { nodeId: string; label: string } | null) => void
} }
export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({ export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
open: false, open: false,
setOpen: (open) => { setOpen: (open) => {
set({ open }) set({ open })
if (!open) set({ pages: [], inputValue: '', cameraScope: null, mode: 'command' }) if (!open) set({ pages: [], inputValue: '', mode: 'command' })
}, },
mode: 'command', mode: 'command',
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
@@ -44,12 +42,8 @@ export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
setInputValue: (value) => set({ inputValue: value }), setInputValue: (value) => set({ inputValue: value }),
navigateTo: (page) => set((s) => ({ pages: [...s.pages, page], inputValue: '' })), navigateTo: (page) => set((s) => ({ pages: [...s.pages, page], inputValue: '' })),
goBack: () => { goBack: () => {
const { pages } = get()
if (pages[pages.length - 1] === 'camera-scope') set({ cameraScope: null })
set((s) => ({ pages: s.pages.slice(0, -1), inputValue: '' })) set((s) => ({ pages: s.pages.slice(0, -1), inputValue: '' }))
}, },
cameraScope: null,
setCameraScope: (scope) => set({ cameraScope: scope }),
})) }))
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -157,8 +151,6 @@ const PAGE_LABEL: Record<string, string> = {
'level-mode': 'Level Mode', 'level-mode': 'Level Mode',
'rename-level': 'Rename Level', 'rename-level': 'Rename Level',
'goto-level': 'Go to Level', 'goto-level': 'Go to Level',
'camera-view': 'Camera Snapshot',
'camera-scope': '',
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -196,19 +188,8 @@ function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) {
// Main component // Main component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) { export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) {
const { const { open, setOpen, mode, setMode, pages, inputValue, setInputValue, navigateTo, goBack } =
open, useCommandPalette()
setOpen,
mode,
setMode,
pages,
inputValue,
setInputValue,
navigateTo,
goBack,
cameraScope,
setCameraScope,
} = useCommandPalette()
const [meta, setMeta] = useState('⌘') const [meta, setMeta] = useState('⌘')
const [isFullscreen, setIsFullscreen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false)
@@ -233,11 +214,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
), ),
) )
const cameraScopeNode = useScene((s) =>
cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null,
)
const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera
// Platform detection // Platform detection
useEffect(() => { useEffect(() => {
setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl') setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl')
@@ -279,7 +255,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
solo: 'Solo', solo: 'Solo',
} }
// Camera snapshot helpers (used by sub-pages registered via EditorCommands)
const confirmRename = () => { const confirmRename = () => {
if (!(activeLevelId && inputValue.trim())) return if (!(activeLevelId && inputValue.trim())) return
run(() => { run(() => {
@@ -287,29 +262,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
}) })
} }
const takeSnapshot = () => {
if (!cameraScope) return
import('@pascal-app/core').then(({ emitter }) => {
run(() =>
emitter.emit('camera-controls:capture', { nodeId: cameraScope.nodeId as AnyNodeId }),
)
})
}
const viewSnapshot = () => {
if (!(cameraScope && hasScopeSnapshot)) return
import('@pascal-app/core').then(({ emitter }) => {
run(() => emitter.emit('camera-controls:view', { nodeId: cameraScope.nodeId as AnyNodeId }))
})
}
const clearSnapshot = () => {
if (!(cameraScope && hasScopeSnapshot)) return
run(() => {
useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any)
})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Group registered actions by group (preserving insertion order) // Group registered actions by group (preserving insertion order)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -363,9 +315,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
onClick={goBack} onClick={goBack}
type="button" type="button"
> >
{page === 'camera-scope' {PAGE_LABEL[page] ?? views.get(page)?.label ?? page}
? (cameraScope?.label ?? 'Snapshot')
: (PAGE_LABEL[page] ?? views.get(page)?.label ?? page)}
</button> </button>
)} )}
<Command.Input <Command.Input
@@ -500,207 +450,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
</Command.Item> </Command.Item>
</Command.Group> </Command.Group>
)} )}
{/* ── Camera Snapshot: scope picker ─────────────────────────── */}
{page === 'camera-view' && (
<Command.Group heading="Camera Snapshot — Select Scope">
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path d="M3 3h18v18H3z" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 9h18M9 21V9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
label="Site"
onSelect={() => {
const { rootNodeIds } = useScene.getState()
const siteId = rootNodeIds[0]
if (siteId) {
setCameraScope({ nodeId: siteId, label: 'Site' })
navigateTo('camera-scope')
}
}}
/>
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="9 22 9 12 15 12 15 22"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label="Building"
onSelect={() => {
const building = Object.values(useScene.getState().nodes).find(
(n) => n.type === 'building',
)
if (building) {
setCameraScope({ nodeId: building.id, label: 'Building' })
navigateTo('camera-scope')
}
}}
/>
<OptionItem
disabled={!activeLevelId}
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M12 2L2 7l10 5 10-5-10-5z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17l10 5 10-5M2 12l10 5 10-5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label="Level"
onSelect={() => {
if (activeLevelId) {
setCameraScope({ nodeId: activeLevelId, label: 'Level' })
navigateTo('camera-scope')
}
}}
/>
<OptionItem
disabled={!useViewer.getState().selection.selectedIds.length}
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path d="M5 3l14 9-14 9V3z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
label="Selection"
onSelect={() => {
const firstId = useViewer.getState().selection.selectedIds[0]
if (firstId) {
setCameraScope({ nodeId: firstId, label: 'Selection' })
navigateTo('camera-scope')
}
}}
/>
</Command.Group>
)}
{/* ── Camera Snapshot: actions for selected scope ───────────── */}
{page === 'camera-scope' && cameraScope && (
<Command.Group heading={`${cameraScope.label} Snapshot`}>
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="13"
r="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label={hasScopeSnapshot ? 'Update Snapshot' : 'Take Snapshot'}
onSelect={takeSnapshot}
/>
{hasScopeSnapshot && (
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="12"
r="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label="View Snapshot"
onSelect={viewSnapshot}
/>
)}
{hasScopeSnapshot && (
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<polyline
points="3 6 5 6 21 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19 6l-1 14H6L5 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10 11v6M14 11v6" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9 6V4h6v2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
label="Clear Snapshot"
onSelect={clearSnapshot}
/>
)}
</Command.Group>
)}
</Command.List> </Command.List>
{/* Footer hint */} {/* Footer hint */}
+8 -5
View File
@@ -4,9 +4,11 @@ import {
getCatalogMaterialById, getCatalogMaterialById,
getLibraryMaterialIdFromRef, getLibraryMaterialIdFromRef,
getMaterialsForCategory, getMaterialsForCategory,
getMaterialsForTarget,
MATERIAL_CATEGORIES, MATERIAL_CATEGORIES,
toLibraryMaterialRef,
type MaterialSchema, type MaterialSchema,
type MaterialTarget,
toLibraryMaterialRef,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -17,6 +19,8 @@ type MaterialPickerProps = {
onChange?: (material: MaterialSchema) => void onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void onSelectMaterialPreset?: (materialPreset: string) => void
disabled?: boolean disabled?: boolean
nodeType?: MaterialTarget
hideSideControl?: boolean
} }
export function MaterialPicker({ export function MaterialPicker({
@@ -48,8 +52,7 @@ export function MaterialPicker({
return return
} }
const catalogId = const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
const selectedCatalogEntry = getCatalogMaterialById(catalogId) const selectedCatalogEntry = getCatalogMaterialById(catalogId)
if (selectedCatalogEntry?.category) { if (selectedCatalogEntry?.category) {
setSelectedCategory(selectedCatalogEntry.category) setSelectedCategory(selectedCatalogEntry.category)
@@ -177,7 +180,7 @@ export function MaterialPicker({
title={item.label} title={item.label}
type="button" type="button"
> >
<div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" /> <div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-white/12 ring-inset" />
{item.previewThumbnailUrl ? ( {item.previewThumbnailUrl ? (
<img <img
alt={item.label} alt={item.label}
@@ -193,7 +196,7 @@ export function MaterialPicker({
))} ))}
{selectedCategory === 'other' && onChange ? ( {selectedCategory === 'other' && onChange ? (
<button <button
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${ className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
showCustom showCustom
? 'border-blue-500 ring-2 ring-blue-500/30' ? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400' : 'border-gray-300 hover:border-gray-400'
+1 -1
View File
@@ -162,7 +162,7 @@ function LevelRow({
{...dragHandleProps} {...dragHandleProps}
aria-label={`Reorder ${getLevelDisplayLabel(level)}`} aria-label={`Reorder ${getLevelDisplayLabel(level)}`}
className={cn( className={cn(
'ml-0.5 flex h-6 w-4 shrink-0 touch-none cursor-grab items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100', 'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
isDragging && 'cursor-grabbing opacity-100', isDragging && 'cursor-grabbing opacity-100',
)} )}
onClick={(e) => { onClick={(e) => {
@@ -1,5 +1,6 @@
'use client' 'use client'
import { useIsMobile } from '../../../hooks/use-mobile'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper' import { BuildingHelper } from './building-helper'
import { CeilingHelper } from './ceiling-helper' import { CeilingHelper } from './ceiling-helper'
@@ -12,6 +13,10 @@ export function HelperManager() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool) const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const isMobile = useIsMobile()
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null
if (movingNode) { if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate /> if (movingNode.type === 'building') return <BuildingHelper showRotate />
File diff suppressed because it is too large Load Diff
@@ -13,13 +13,53 @@ import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor' import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { CATALOG_ITEMS } from './catalog-items' import { CATALOG_ITEMS } from './catalog-items'
export function ItemCatalog({ category }: { category: CatalogCategory }) { export function ItemCatalog({
category,
items: itemsOverride,
activePlacementTag = null,
activeFunctionalTag = null,
search = '',
overrideItems,
leadingTile,
emptyState,
}: {
category: CatalogCategory
items?: AssetInput[]
activePlacementTag?: string | null
activeFunctionalTag?: string | null
search?: string
/** When set, bypasses all filtering and displays these items directly (used for server search results) */
overrideItems?: AssetInput[]
/** Rendered as the first grid cell, always visible when there are items. */
leadingTile?: React.ReactNode
/** Rendered when there are no items to show. Replaces the empty grid. */
emptyState?: React.ReactNode
}) {
const selectedItem = useEditor((state) => state.selectedItem) const selectedItem = useEditor((state) => state.selectedItem)
const setSelectedItem = useEditor((state) => state.setSelectedItem) const setSelectedItem = useEditor((state) => state.setSelectedItem)
const setMode = useEditor((state) => state.setMode)
const setTool = useEditor((state) => state.setTool)
const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category) const sourceItems = itemsOverride ?? CATALOG_ITEMS
// Server-provided results bypass all local filtering; otherwise filter by category/search/tags
const filteredItems =
overrideItems ??
(() => {
const categoryItems = search
? sourceItems
: sourceItems.filter((item) => item.category === category)
return categoryItems.filter((item) => {
const tags = item.tags ?? []
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false
return true
})
})()
// Auto-select first item if current selection is not in this category const categoryItems = filteredItems
// Auto-select first item if current selection is not in the filtered list
useEffect(() => { useEffect(() => {
const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src) const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
if (!isCurrentItemInCategory && categoryItems.length > 0) { if (!isCurrentItemInCategory && categoryItems.length > 0) {
@@ -27,58 +67,60 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
} }
}, [categoryItems, selectedItem?.src, setSelectedItem]) }, [categoryItems, selectedItem?.src, setSelectedItem])
// Get attachment icon based on attachTo type
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => { const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
if (attachTo === 'wall' || attachTo === 'wall-side') { if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
return '/icons/wall.png' if (attachTo === 'ceiling') return '/icons/ceiling.png'
}
if (attachTo === 'ceiling') {
return '/icons/ceiling.png'
}
return null return null
} }
if (filteredItems.length === 0 && emptyState) {
return <>{emptyState}</>
}
return ( return (
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2"> <div
{categoryItems.map((item, index) => { className="grid gap-2"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))' }}
>
{leadingTile}
{filteredItems.map((item, index) => {
const isSelected = selectedItem?.src === item?.src const isSelected = selectedItem?.src === item?.src
const attachmentIcon = getAttachmentIcon(item?.attachTo) const attachmentIcon = getAttachmentIcon(item?.attachTo)
return ( return (
<Tooltip key={index}> <button
<TooltipTrigger asChild> className={cn(
<button 'group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent',
className={cn( isSelected && 'bg-sidebar-accent ring-2 ring-primary-foreground',
'relative aspect-square h-14 min-h-14 w-14 min-w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer', )}
isSelected && 'ring-2 ring-primary-foreground', key={index}
)} onClick={() => {
onClick={() => setSelectedItem(item)} setSelectedItem(item)
type="button" setTool('item')
> setMode('build')
<Image }}
alt={item.name} type="button"
className="rounded-lg object-cover" >
fill <div className="relative aspect-square w-full overflow-hidden rounded-lg">
loading="eager" <img
sizes="56px" alt={item.name}
src={resolveCdnUrl(item.thumbnail) || ''} className="h-full w-full object-cover"
/> loading="eager"
{attachmentIcon && ( src={resolveCdnUrl(item.thumbnail) || ''}
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60"> />
<Image {attachmentIcon && (
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'} <div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
className="h-4 w-4" <img
height={16} alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
src={attachmentIcon} className="h-4 w-4"
width={16} src={attachmentIcon}
/> />
</div> </div>
)} )}
</button> </div>
</TooltipTrigger> <span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
<TooltipContent className="text-xs" side="top">
{item.name} {item.name}
</TooltipContent> </span>
</Tooltip> </button>
) )
})} })}
</div> </div>
@@ -2,8 +2,8 @@
import type { LevelNode } from '@pascal-app/core' import type { LevelNode } from '@pascal-app/core'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { cn } from '../../lib/utils'
import type { LevelDuplicatePreset } from '../../lib/level-duplication' import type { LevelDuplicatePreset } from '../../lib/level-duplication'
import { cn } from '../../lib/utils'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -69,9 +69,7 @@ export function LevelDuplicateDialog({
<DialogContent className="sm:max-w-md" showCloseButton={false}> <DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle>Duplicate Level</DialogTitle> <DialogTitle>Duplicate Level</DialogTitle>
<DialogDescription> <DialogDescription>Choose what to copy from {getLevelLabel(level)}.</DialogDescription>
Choose what to copy from {getLevelLabel(level)}.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -95,7 +93,7 @@ export function LevelDuplicateDialog({
<DialogFooter> <DialogFooter>
<button <button
className="cursor-pointer rounded-md px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent" className="cursor-pointer rounded-md px-4 py-2 text-muted-foreground text-sm transition-colors hover:bg-accent"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
type="button" type="button"
> >
+2 -3
View File
@@ -120,9 +120,8 @@ export function CeilingPanel() {
const n = polygon.length const n = polygon.length
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
const current = polygon[i] const current = polygon[i]!
const next = polygon[j] const next = polygon[j]!
if (!(current && next)) continue
area += current[0] * next[1] area += current[0] * next[1]
area -= next[0] * current[1] area -= next[0] * current[1]
} }
@@ -70,10 +70,12 @@ const COLUMN_PROPORTION_PRESETS = {
type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({ const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(
value: value as ColumnProportionPresetId, ([value, preset]) => ({
label: preset.label, value: value as ColumnProportionPresetId,
})) label: preset.label,
}),
)
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)) return Math.min(max, Math.max(min, value))
@@ -201,7 +203,12 @@ export function ColumnPanel() {
const shaftProfile = node.shaftProfile ?? 'straight' const shaftProfile = node.shaftProfile ?? 'straight'
return ( return (
<PanelWrapper icon="/icons/column.png" onClose={handleClose} title={node.name || 'Column'} width={300}> <PanelWrapper
icon="/icons/column.png"
onClose={handleClose}
title={node.name || 'Column'}
width={300}
>
<PanelSection title="Preset"> <PanelSection title="Preset">
<select <select
className={SELECT_CLASS} className={SELECT_CLASS}
@@ -223,7 +230,9 @@ export function ColumnPanel() {
<PanelSection title="Shape"> <PanelSection title="Shape">
<select <select
className={SELECT_CLASS} className={SELECT_CLASS}
onChange={(event) => handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })} onChange={(event) =>
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
}
value={node.crossSection} value={node.crossSection}
> >
<option value="round">Round</option> <option value="round">Round</option>
@@ -313,7 +322,9 @@ export function ColumnPanel() {
<PanelSection title="Shaft"> <PanelSection title="Shaft">
<select <select
className={SELECT_CLASS} className={SELECT_CLASS}
onChange={(event) => handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))} onChange={(event) =>
handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))
}
value={shaftProfile} value={shaftProfile}
> >
<option value="straight">Straight</option> <option value="straight">Straight</option>
@@ -487,10 +498,22 @@ export function ColumnPanel() {
? {} ? {}
: { : {
capitalHeight: Math.max(node.capitalHeight, 0.12), capitalHeight: Math.max(node.capitalHeight, 0.12),
capitalTierCount: capitalStyle === 'stepped' ? Math.max(node.capitalTierCount ?? 3, 3) : node.capitalTierCount, capitalTierCount:
capitalWidthScale: Math.max(node.capitalWidthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28), capitalStyle === 'stepped'
capitalDepthScale: Math.max(node.capitalDepthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28), ? Math.max(node.capitalTierCount ?? 3, 3)
capitalStepSpread: capitalStyle === 'stepped' ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) : node.capitalStepSpread, : node.capitalTierCount,
capitalWidthScale: Math.max(
node.capitalWidthScale ?? 1.3,
capitalStyle === 'stepped' ? 1.42 : 1.28,
),
capitalDepthScale: Math.max(
node.capitalDepthScale ?? 1.3,
capitalStyle === 'stepped' ? 1.42 : 1.28,
),
capitalStepSpread:
capitalStyle === 'stepped'
? Math.max(node.capitalStepSpread ?? 0.34, 0.34)
: node.capitalStepSpread,
}), }),
}) })
}} }}
@@ -572,13 +595,34 @@ export function ColumnPanel() {
? {} ? {}
: { : {
baseHeight: Math.max(node.baseHeight, 0.12), baseHeight: Math.max(node.baseHeight, 0.12),
baseTierCount: baseStyle === 'stepped-square' ? Math.max(node.baseTierCount ?? 3, 3) : node.baseTierCount, baseTierCount:
baseWidthScale: Math.max(node.baseWidthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24), baseStyle === 'stepped-square'
baseDepthScale: Math.max(node.baseDepthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24), ? Math.max(node.baseTierCount ?? 3, 3)
baseStepSpread: baseStyle === 'stepped-square' ? Math.max(node.baseStepSpread ?? 0.34, 0.34) : node.baseStepSpread, : node.baseTierCount,
basePlinthHeightRatio: baseStyle === 'round-rings' ? (node.basePlinthHeightRatio ?? 0.44) : node.basePlinthHeightRatio, baseWidthScale: Math.max(
baseRoundBandScale: baseStyle === 'round-rings' ? (node.baseRoundBandScale ?? 0.92) : node.baseRoundBandScale, node.baseWidthScale ?? 1.24,
baseNeckScale: baseStyle === 'round-rings' ? (node.baseNeckScale ?? 0.72) : node.baseNeckScale, baseStyle === 'stepped-square' ? 1.42 : 1.24,
),
baseDepthScale: Math.max(
node.baseDepthScale ?? 1.24,
baseStyle === 'stepped-square' ? 1.42 : 1.24,
),
baseStepSpread:
baseStyle === 'stepped-square'
? Math.max(node.baseStepSpread ?? 0.34, 0.34)
: node.baseStepSpread,
basePlinthHeightRatio:
baseStyle === 'round-rings'
? (node.basePlinthHeightRatio ?? 0.44)
: node.basePlinthHeightRatio,
baseRoundBandScale:
baseStyle === 'round-rings'
? (node.baseRoundBandScale ?? 0.92)
: node.baseRoundBandScale,
baseNeckScale:
baseStyle === 'round-rings'
? (node.baseNeckScale ?? 0.72)
: node.baseNeckScale,
}), }),
}) })
}} }}
+278 -271
View File
@@ -12,8 +12,8 @@ import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react' import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context' import { usePresetsAdapter } from '../../../contexts/presets-context'
import { cn } from '../../../lib/utils'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control' import { MetricControl } from '../controls/metric-control'
@@ -148,7 +148,13 @@ export function DoorPanel() {
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'door') return if (liveNode?.type !== 'door') return
if (!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)) { if (
!(
previewRef.current &&
previewRef.current.id === selectedId &&
previewRef.current.key === key
)
) {
previewRef.current = { previewRef.current = {
id: selectedId as AnyNodeId, id: selectedId as AnyNodeId,
key, key,
@@ -333,7 +339,8 @@ export function DoorPanel() {
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum) const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
const isOpening = node.openingKind === 'opening' const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle' const openingShape = node.openingShape ?? 'rectangle'
const doorShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle' const doorShape =
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all' const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15] const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15 const cornerRadius = node.cornerRadius ?? 0.15
@@ -578,7 +585,8 @@ export function DoorPanel() {
isSelected isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground' ? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground', : 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
!option.available && 'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground', !option.available &&
'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground',
)} )}
disabled={!option.available} disabled={!option.available}
key={option.value} key={option.value}
@@ -962,312 +970,311 @@ export function DoorPanel() {
/> />
</PanelSection> </PanelSection>
{!isGarageDoor && ( {!isGarageDoor && (
<PanelSection title="Content Padding"> <PanelSection title="Content Padding">
<SliderControl
label="Horizontal"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[0] * 1000) / 1000}
/>
<SliderControl
label="Vertical"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
/>
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Hinges Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.hingesSide}
/>
</div>
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
value={node.swingDirection}
/>
</div>
</div>
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Threshold">
<ToggleControl
checked={node.threshold}
label="Enable Threshold"
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl <SliderControl
label="Height" label="Horizontal"
max={0.1} max={0.2}
min={0.005} min={0}
onChange={(v) => handleUpdate({ thresholdHeight: v })} onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
precision={3} precision={3}
step={0.005} step={0.005}
unit="m" unit="m"
value={Math.round(node.thresholdHeight * 1000) / 1000} value={Math.round(node.contentPadding[0] * 1000) / 1000}
/> />
</div>
)}
</PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Handle">
{isSwingDoor && (
<ToggleControl
checked={node.handle}
label="Enable Handle"
onChange={(checked) => handleUpdate({ handle: checked })}
/>
)}
{(node.handle || !isSwingDoor) && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl <SliderControl
label="Height" label="Vertical"
max={node.height - 0.1} max={0.2}
min={0.5} min={0}
onChange={(v) => handleUpdate({ handleHeight: v })} onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
precision={2} precision={3}
step={0.05} step={0.005}
unit="m" unit="m"
value={Math.round(node.handleHeight * 100) / 100} value={Math.round(node.contentPadding[1] * 1000) / 1000}
/> />
{supportsHandleSide && ( </PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1"> <div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider"> <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Handle Side Hinges Side
</span> </span>
<SegmentedControl <SegmentedControl
onChange={(v) => handleUpdate({ handleSide: v })} onChange={(v) => handleUpdate({ hingesSide: v })}
options={[ options={[
{ label: 'Left', value: 'left' }, { label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' }, { label: 'Right', value: 'right' },
]} ]}
value={node.handleSide} value={node.hingesSide}
/>
</div>
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
value={node.swingDirection}
/>
</div>
</div>
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Threshold">
<ToggleControl
checked={node.threshold}
label="Enable Threshold"
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.thresholdHeight * 1000) / 1000}
/> />
</div> </div>
)} )}
</div> </PanelSection>
)} )}
</PanelSection>
)}
{isSwingDoor && ( {!isGarageDoor && (
<PanelSection title="Hardware"> <PanelSection title="Handle">
<ToggleControl {isSwingDoor && (
checked={node.doorCloser} <ToggleControl
label="Door Closer" checked={node.handle}
onChange={(checked) => handleUpdate({ doorCloser: checked })} label="Enable Handle"
/> onChange={(checked) => handleUpdate({ handle: checked })}
<ToggleControl
checked={node.panicBar}
label="Panic Bar"
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.panicBarHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map((r) => r / colSum)
return (
<div className="mb-2 flex flex-col gap-1" key={i}>
<div className="flex items-center justify-between pb-1">
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
</div>
<SegmentedControl
onChange={(t) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated })
}}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
value={seg.type}
/> />
)}
<SliderControl {(node.handle || !isSwingDoor) && (
label="Height" <div className="mt-1 flex flex-col gap-1">
max={95} <SliderControl
min={5} label="Height"
onChange={(v) => setSegmentHeightRatio(i, v / 100)} max={node.height - 0.1}
precision={1} min={0.5}
step={1} onChange={(v) => handleUpdate({ handleHeight: v })}
unit="%" precision={2}
value={Math.round(normHeights[i]! * 100 * 10) / 10} step={0.05}
/> unit="m"
value={Math.round(node.handleHeight * 100) / 100}
<SliderControl />
label="Columns" {supportsHandleSide && (
max={8} <div className="space-y-1">
min={1} <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
onChange={(v) => { Handle Side
const n = Math.max(1, Math.min(8, Math.round(v))) </span>
const updated = node.segments.map((s, idx) => <SegmentedControl
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s, onChange={(v) => handleUpdate({ handleSide: v })}
) options={[
handleUpdate({ segments: updated }) { label: 'Left', value: 'left' },
}} { label: 'Right', value: 'right' },
precision={0} ]}
step={1} value={node.handleSide}
value={numCols}
/>
{numCols > 1 && (
<div className="mt-1 border-border/50 border-t pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
max={95}
min={5}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/> />
))} </div>
<SliderControl )}
label="Divider" </div>
max={0.1} )}
min={0.005} </PanelSection>
onChange={(v) => { )}
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.dividerThickness * 1000) / 1000}
/>
</div>
)}
{seg.type === 'panel' && ( {isSwingDoor && (
<div className="mt-1 border-border/50 border-t pt-1"> <PanelSection title="Hardware">
<SliderControl <ToggleControl
label="Inset" checked={node.doorCloser}
max={0.1} label="Door Closer"
min={0} onChange={(checked) => handleUpdate({ doorCloser: checked })}
onChange={(v) => { />
<ToggleControl
checked={node.panicBar}
label="Panic Bar"
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.panicBarHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map((r) => r / colSum)
return (
<div className="mb-2 flex flex-col gap-1" key={i}>
<div className="flex items-center justify-between pb-1">
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
</div>
<SegmentedControl
onChange={(t) => {
const updated = node.segments.map((s, idx) => const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s, idx === i ? { ...s, type: t } : s,
) )
handleUpdate({ segments: updated }) handleUpdate({ segments: updated })
}} }}
precision={3} options={[
step={0.005} { label: 'Panel', value: 'panel' },
unit="m" { label: 'Glass', value: 'glass' },
value={Math.round(seg.panelInset * 1000) / 1000} { label: 'Empty', value: 'empty' },
]}
value={seg.type}
/> />
<SliderControl <SliderControl
label="Depth" label="Height"
max={0.1} max={95}
min={0} min={5}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
/>
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => { onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) => const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s, idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
) )
handleUpdate({ segments: updated }) handleUpdate({ segments: updated })
}} }}
precision={3} precision={0}
step={0.005} step={1}
unit="m" value={numCols}
value={Math.round(seg.panelDepth * 1000) / 1000}
/> />
{numCols > 1 && (
<div className="mt-1 border-border/50 border-t pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
max={95}
min={5}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.dividerThickness * 1000) / 1000}
/>
</div>
)}
{seg.type === 'panel' && (
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Inset"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelInset * 1000) / 1000}
/>
<SliderControl
label="Depth"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelDepth * 1000) / 1000}
/>
</div>
)}
</div> </div>
)
})}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{
type: 'panel' as const,
heightRatio: 1,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<ActionButton
className="text-white/60 hover:text-white"
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
/>
)} )}
</div> </div>
) </PanelSection>
})} )}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{
type: 'panel' as const,
heightRatio: 1,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<ActionButton
className="text-white/60 hover:text-white"
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
/>
)}
</div>
</PanelSection>
)}
</> </>
)} )}
@@ -1,6 +1,5 @@
'use client' 'use client'
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
@@ -86,10 +85,6 @@ export function FencePanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const length = getWallCurveLength(node) const length = getWallCurveLength(node)
@@ -1,9 +1,8 @@
'use client' 'use client'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { SliderControl } from '../controls/slider-control'
import { Input } from '../primitives/input'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { Input } from '../primitives/input'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildDefaultCustomMaterial() { function buildDefaultCustomMaterial() {
@@ -53,11 +52,7 @@ export function PaintPanel() {
} }
return ( return (
<PanelWrapper <PanelWrapper onClose={() => setPaintPanelOpen(false)} title="Material" width={320}>
onClose={() => setPaintPanelOpen(false)}
title="Material"
width={320}
>
<PanelSection title="Custom Material"> <PanelSection title="Custom Material">
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
@@ -78,41 +73,71 @@ export function PaintPanel() {
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-2">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]"> <div className="flex items-center justify-between">
Surface <label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
</label> Roughness
<div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2"> </label>
<SliderControl <span className="font-mono text-muted-foreground text-xs">
label="Roughness" {currentProps.roughness.toFixed(2)}
max={1} </span>
min={0}
onChange={(roughness) => updateCustomMaterial({ roughness })}
precision={2}
step={0.01}
value={currentProps.roughness}
/>
<SliderControl
label="Metalness"
max={1}
min={0}
onChange={(metalness) => updateCustomMaterial({ metalness })}
precision={2}
step={0.01}
value={currentProps.metalness}
/>
<SliderControl
label="Opacity"
max={1}
min={0}
onChange={(opacity) =>
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}
precision={2}
step={0.01}
value={currentProps.opacity}
/>
</div> </div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) =>
updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })
}
step={0.01}
type="range"
value={currentProps.roughness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Metalness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) =>
updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })
}
step={0.01}
type="range"
value={currentProps.metalness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Opacity
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => {
const opacity = Number.parseFloat(e.target.value)
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
+3 -32
View File
@@ -92,6 +92,8 @@ function panelForType(type: string | null) {
return <StairSegmentPanel /> return <StairSegmentPanel />
case 'slab': case 'slab':
return <SlabPanel /> return <SlabPanel />
case 'spawn':
return <SpawnPanel />
case 'ceiling': case 'ceiling':
return <CeilingPanel /> return <CeilingPanel />
case 'column': case 'column':
@@ -238,36 +240,5 @@ export function PanelManager() {
} }
// Show appropriate panel based on selected node type // Show appropriate panel based on selected node type
if (selectedNodeType) { return panelForType(selectedNodeType)
switch (selectedNodeType) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'spawn':
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'fence':
return <FencePanel />
case 'door':
return <DoorPanel />
case 'window':
return <WindowPanel />
}
}
return null
} }
@@ -4,11 +4,21 @@ import {
type AnyNode, type AnyNode,
type GuideNode, type GuideNode,
loadAssetUrl, loadAssetUrl,
saveAsset,
type ScanNode, type ScanNode,
saveAsset,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react' import {
Eye,
EyeOff,
LocateFixed,
Lock,
RotateCcw,
Ruler,
Trash2,
Unlock,
Upload,
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { guideEmitter } from '../../../lib/guide-events' import { guideEmitter } from '../../../lib/guide-events'
import { getGuideImageName } from '../../../lib/local-guide-image' import { getGuideImageName } from '../../../lib/local-guide-image'
@@ -32,7 +42,9 @@ function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
export function ReferencePanel() { export function ReferencePanel() {
const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined)) const guideUi = useEditor((s) =>
selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined,
)
const setGuideLocked = useEditor((s) => s.setGuideLocked) const setGuideLocked = useEditor((s) => s.setGuideLocked)
const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible) const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
const clearGuideUi = useEditor((s) => s.clearGuideUi) const clearGuideUi = useEditor((s) => s.clearGuideUi)
@@ -77,11 +89,14 @@ export function ReferencePanel() {
try { try {
const assetUrl = await saveAsset(file) const assetUrl = await saveAsset(file)
updateNode(selectedReferenceId as AnyNode['id'], { updateNode(
name: getGuideImageName(file.name), selectedReferenceId as AnyNode['id'],
url: assetUrl, {
scaleReference: null, name: getGuideImageName(file.name),
} as Partial<GuideNode>) url: assetUrl,
scaleReference: null,
} as Partial<GuideNode>,
)
setGuideScaleReferenceVisible(selectedReferenceId, true) setGuideScaleReferenceVisible(selectedReferenceId, true)
} catch { } catch {
setReplaceError('Could not replace that image.') setReplaceError('Could not replace that image.')
@@ -138,7 +153,7 @@ export function ReferencePanel() {
const isScan = node.type === 'scan' const isScan = node.type === 'scan'
const guideLocked = !isScan && guideUi?.locked === true const guideLocked = !isScan && guideUi?.locked === true
const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null const scaleStatus = isScan ? null : getScaleStatus(node, scaleReferenceVisible)
return ( return (
<PanelWrapper <PanelWrapper
@@ -165,16 +180,16 @@ export function ReferencePanel() {
<ActionGroup> <ActionGroup>
<ActionButton <ActionButton
disabled={isReplacing}
icon={<Upload className="h-3.5 w-3.5" />} icon={<Upload className="h-3.5 w-3.5" />}
label={isReplacing ? 'Replacing...' : 'Replace'} label={isReplacing ? 'Replacing...' : 'Replace'}
onClick={() => replaceInputRef.current?.click()} onClick={() => replaceInputRef.current?.click()}
disabled={isReplacing}
/> />
<ActionButton <ActionButton
className="text-destructive hover:bg-destructive/10"
icon={<Trash2 className="h-3.5 w-3.5" />} icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete" label="Delete"
onClick={handleDeleteGuide} onClick={handleDeleteGuide}
className="text-destructive hover:bg-destructive/10"
/> />
</ActionGroup> </ActionGroup>
@@ -232,16 +247,16 @@ export function ReferencePanel() {
<ActionGroup> <ActionGroup>
<ActionButton <ActionButton
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
disabled={!node.scaleReference} disabled={!node.scaleReference}
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
onClick={() => { onClick={() => {
if (!node.scaleReference) return if (!node.scaleReference) return
setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible) setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
}} }}
/> />
<ActionButton <ActionButton
label="Clear Scale"
disabled={!node.scaleReference} disabled={!node.scaleReference}
label="Clear Scale"
onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)} onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
/> />
</ActionGroup> </ActionGroup>
+52 -2
View File
@@ -3,21 +3,25 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
type MaterialSchema,
type RoofNode, type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema, RoofNode as RoofNodeSchema,
type RoofSegmentNode, type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema, RoofSegmentNode as RoofSegmentNodeSchema,
type RoofSurfaceMaterialRole,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus' import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint'
import { duplicateRoofSubtree } from '../../../lib/roof-duplication' import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
@@ -28,6 +32,7 @@ export function RoofPanel() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -50,6 +55,35 @@ export function RoofPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'top' ||
selectedMaterialTarget.role === 'edge' ||
selectedMaterialTarget.role === 'wall')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetRole)) return
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate, materialTargetRole, node],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetRole)) return
handleUpdate(
buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
)
},
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -225,6 +259,22 @@ export function RoofPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
{materialTargetRole ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the roof surface you want to edit. Materials apply to one target at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="roof"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
View File
View File
@@ -87,7 +87,7 @@ export function SpawnPanel() {
if (!(node && node.type === 'spawn' && selectedId)) return null if (!(node && node.type === 'spawn' && selectedId)) return null
const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI)) const rotationDegrees = Math.round(((draftRotation ?? node.rotation) * 180) / Math.PI)
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI) const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
return ( return (
@@ -97,7 +97,9 @@ export function SpawnPanel() {
label="X" label="X"
max={node.position[0] + 2} max={node.position[0] + 2}
min={node.position[0] - 2} min={node.position[0] - 2}
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })} onChange={(value) =>
handleUpdate({ position: [value, node.position[1], node.position[2]] })
}
precision={2} precision={2}
step={0.01} step={0.01}
unit="m" unit="m"
@@ -107,7 +109,9 @@ export function SpawnPanel() {
label="Y" label="Y"
max={node.position[1] + 2} max={node.position[1] + 2}
min={node.position[1] - 2} min={node.position[1] - 2}
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })} onChange={(value) =>
handleUpdate({ position: [node.position[0], value, node.position[2]] })
}
precision={2} precision={2}
step={0.01} step={0.01}
unit="m" unit="m"
@@ -117,7 +121,9 @@ export function SpawnPanel() {
label="Z" label="Z"
max={node.position[2] + 2} max={node.position[2] + 2}
min={node.position[2] - 2} min={node.position[2] - 2}
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })} onChange={(value) =>
handleUpdate({ position: [node.position[0], node.position[1], value] })
}
precision={2} precision={2}
step={0.01} step={0.01}
unit="m" unit="m"
@@ -3,25 +3,31 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveStairSurfaceMaterial,
type LevelNode, type LevelNode,
type MaterialSchema,
type StairNode, type StairNode,
StairNode as StairNodeSchema,
type StairRailingMode, type StairRailingMode,
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
type StairSegmentNode, type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema, StairSegmentNode as StairSegmentNodeSchema,
type StairSlabOpeningMode,
type StairSurfaceMaterialRole,
type StairTopLandingMode,
type StairType,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control' import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
@@ -59,6 +65,7 @@ export function StairPanel() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -89,6 +96,35 @@ export function StairPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'railing' ||
selectedMaterialTarget.role === 'tread' ||
selectedMaterialTarget.role === 'side')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetRole)) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate, materialTargetRole, node],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetRole)) return
handleUpdate(
buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
)
},
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -222,11 +258,11 @@ export function StairPanel() {
/> />
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground"> <div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
From Level From Level
</div> </div>
<select <select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground" className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })} onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
value={resolvedFromLevelId ?? ''} value={resolvedFromLevelId ?? ''}
> >
@@ -239,11 +275,11 @@ export function StairPanel() {
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground"> <div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
To Level To Level
</div> </div>
<select <select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground" className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ toLevelId: event.target.value })} onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
value={resolvedToLevelId ?? ''} value={resolvedToLevelId ?? ''}
> >
@@ -262,7 +298,7 @@ export function StairPanel() {
/> />
{(node.slabOpeningMode ?? 'none') === 'destination' ? ( {(node.slabOpeningMode ?? 'none') === 'destination' ? (
<SliderControl <MetricControl
label="Opening Offset" label="Opening Offset"
max={0.5} max={0.5}
min={0} min={0}
@@ -308,7 +344,7 @@ export function StairPanel() {
{(node.stairType === 'curved' || node.stairType === 'spiral') && ( {(node.stairType === 'curved' || node.stairType === 'spiral') && (
<PanelSection title="Geometry"> <PanelSection title="Geometry">
<SliderControl <MetricControl
label="Width" label="Width"
max={10} max={10}
min={0.4} min={0.4}
@@ -318,7 +354,7 @@ export function StairPanel() {
unit="m" unit="m"
value={Math.round((node.width ?? 1) * 100) / 100} value={Math.round((node.width ?? 1) * 100) / 100}
/> />
<SliderControl <MetricControl
label="Rise" label="Rise"
max={10} max={10}
min={0.2} min={0.2}
@@ -328,7 +364,7 @@ export function StairPanel() {
unit="m" unit="m"
value={Math.round((node.totalRise ?? 2.5) * 100) / 100} value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
/> />
<SliderControl <MetricControl
label="Steps" label="Steps"
max={32} max={32}
min={2} min={2}
@@ -346,7 +382,7 @@ export function StairPanel() {
/> />
)} )}
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && ( {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
<SliderControl <MetricControl
label="Thickness" label="Thickness"
max={1} max={1}
min={0.02} min={0.02}
@@ -357,7 +393,7 @@ export function StairPanel() {
value={Math.round((node.thickness ?? 0.25) * 100) / 100} value={Math.round((node.thickness ?? 0.25) * 100) / 100}
/> />
)} )}
<SliderControl <MetricControl
label="Inner Radius" label="Inner Radius"
max={10} max={10}
min={node.stairType === 'spiral' ? 0.05 : 0.2} min={node.stairType === 'spiral' ? 0.05 : 0.2}
@@ -385,7 +421,7 @@ export function StairPanel() {
value={node.topLandingMode ?? 'none'} value={node.topLandingMode ?? 'none'}
/> />
{(node.topLandingMode ?? 'none') === 'integrated' && ( {(node.topLandingMode ?? 'none') === 'integrated' && (
<SliderControl <MetricControl
label="Top Landing" label="Top Landing"
max={5} max={5}
min={0.3} min={0.3}
@@ -520,6 +556,22 @@ export function StairPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
{materialTargetRole ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the stair surface you want to edit. Materials apply to one target at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="stair"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
+97 -1
View File
@@ -4,28 +4,60 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getClampedWallCurveOffset, getClampedWallCurveOffset,
getEffectiveWallSurfaceMaterial,
getMaxWallCurveOffset, getMaxWallCurveOffset,
getWallCurveLength, getWallCurveLength,
getWallSurfaceMaterialSignature,
type MaterialSchema,
normalizeWallCurveOffset, normalizeWallCurveOffset,
useScene, useScene,
type WallNode, type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react' import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react' import { useCallback, useMemo } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide | null,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === null || targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === null || targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function WallPanel() { export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -56,6 +88,35 @@ export function WallPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const effectiveInteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
[node],
)
const effectiveExteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
[node],
)
const surfaceMaterialsMatch = useMemo(
() =>
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
[effectiveExteriorMaterial, effectiveInteriorMaterial],
)
const materialTargetSide =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
? selectedMaterialTarget.role
: null
const materialPickerValue =
materialTargetSide === 'interior'
? effectiveInteriorMaterial
: materialTargetSide === 'exterior'
? effectiveExteriorMaterial
: surfaceMaterialsMatch
? effectiveInteriorMaterial
: {}
const handleUpdateLength = useCallback( const handleUpdateLength = useCallback(
(newLength: number) => { (newLength: number) => {
if (!node || newLength <= 0) return if (!node || newLength <= 0) return
@@ -79,6 +140,24 @@ export function WallPanel() {
[node, handleUpdate], [node, handleUpdate],
) )
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetSide)) return
handleUpdate(
buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset),
)
},
[handleUpdate, materialTargetSide, node],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetSide)) return
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
},
[handleUpdate, materialTargetSide, node],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -160,6 +239,23 @@ export function WallPanel() {
)} )}
</PanelSection> </PanelSection>
<PanelSection title="Material">
{materialTargetSide ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the wall face you want to edit. Materials now apply to one side at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetSide}
hideSideControl
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />

Some files were not shown because too many files have changed in this diff Show More