Merge pull request #298 from pascalorg/chore/community-merge
Chore/community merge
This commit is contained in:
@@ -10,7 +10,9 @@ on:
|
||||
options:
|
||||
- core
|
||||
- viewer
|
||||
- both
|
||||
- editor
|
||||
- mcp
|
||||
- all
|
||||
bump:
|
||||
description: "Version bump"
|
||||
required: true
|
||||
@@ -51,56 +53,104 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Bump & publish core
|
||||
if: inputs.package == 'core' || inputs.package == 'both'
|
||||
- name: Bump versions and sync inter-package references
|
||||
run: |
|
||||
BUMP=${{ inputs.bump }}
|
||||
TARGET=${{ inputs.package }}
|
||||
|
||||
bump_version() {
|
||||
local v=$1
|
||||
IFS='.' read -r MAJ MIN PAT <<< "$v"
|
||||
if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi
|
||||
if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi
|
||||
if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi
|
||||
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
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
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
|
||||
|
||||
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
|
||||
else
|
||||
npm publish --access public
|
||||
echo "📦 Published @pascal-app/core@$VERSION"
|
||||
echo "📦 Published @pascal-app/core@$CORE_VERSION"
|
||||
fi
|
||||
|
||||
- name: Bump & publish viewer
|
||||
if: inputs.package == 'viewer' || inputs.package == 'both'
|
||||
- name: Build & publish viewer
|
||||
if: inputs.package == 'viewer' || inputs.package == 'all'
|
||||
working-directory: packages/viewer
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
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
|
||||
|
||||
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
|
||||
else
|
||||
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
|
||||
|
||||
- name: Commit version bumps & tag
|
||||
@@ -118,6 +168,14 @@ jobs:
|
||||
PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION"
|
||||
TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION"
|
||||
fi
|
||||
if [ -n "$EDITOR_VERSION" ]; then
|
||||
PKGS="$PKGS @pascal-app/editor@$EDITOR_VERSION"
|
||||
TAGS="$TAGS @pascal-app/editor@$EDITOR_VERSION"
|
||||
fi
|
||||
if [ -n "$MCP_VERSION" ]; then
|
||||
PKGS="$PKGS @pascal-app/mcp@$MCP_VERSION"
|
||||
TAGS="$TAGS @pascal-app/mcp@$MCP_VERSION"
|
||||
fi
|
||||
|
||||
git commit -m "release:${PKGS}"
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ function parseIfMatch(raw: string | null): number | undefined {
|
||||
const inner = match ? match[1] : trimmed
|
||||
if (!inner) return undefined
|
||||
const n = Number(inner)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return undefined
|
||||
if (!(Number.isFinite(n) && Number.isInteger(n)) || n < 0) return undefined
|
||||
return n
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "../../../styles/elevation.css";
|
||||
@source "../../../packages/editor/src";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
'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'
|
||||
|
||||
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
|
||||
const SIDEBAR_TABS = [
|
||||
{
|
||||
id: 'site',
|
||||
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
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Environment variable validation for the editor app.
|
||||
*
|
||||
* This file validates that required environment variables are set at runtime.
|
||||
* Variables are defined in the root .env file.
|
||||
* This file validates environment variables used by the standalone editor app.
|
||||
* Values are loaded from the repo root .env.local by package scripts.
|
||||
*
|
||||
* @see https://env.t3.gg/docs/nextjs
|
||||
*/
|
||||
@@ -13,42 +13,21 @@ export const env = createEnv({
|
||||
/**
|
||||
* Server-side environment variables (not exposed to client)
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
server: {},
|
||||
|
||||
/**
|
||||
* Client-side environment variables (exposed to browser via NEXT_PUBLIC_)
|
||||
*/
|
||||
client: {
|
||||
NEXT_PUBLIC_SUPABASE_URL: z.string().min(1),
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_ASSETS_CDN_URL: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
* Runtime values - pulls from process.env
|
||||
*/
|
||||
runtimeEnv: {
|
||||
// Server
|
||||
POSTGRES_URL: process.env.POSTGRES_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,
|
||||
NEXT_PUBLIC_ASSETS_CDN_URL:
|
||||
process.env.NEXT_PUBLIC_ASSETS_CDN_URL ?? process.env.NEXT_PUBLIC_EDITOR_ASSETS_CDN_URL,
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
logging: {
|
||||
browserToTerminal: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e ./.env.local --override -- next dev --port 3002",
|
||||
"build": "dotenv -e ./.env.local --override -- next build",
|
||||
"dev": "dotenv -e ../../.env.local -- next dev --port 3002",
|
||||
"build": "dotenv -e ../../.env.local -- next build",
|
||||
"start": "next start",
|
||||
"lint": "biome lint",
|
||||
"check-types": "next typegen && tsc --noEmit"
|
||||
@@ -21,6 +21,7 @@
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.1",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
@@ -37,9 +38,9 @@
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"agentation": "^2.3.2",
|
||||
"react-grab": "^0.1.25",
|
||||
"react-grab": "^0.1.29",
|
||||
"react-scan": "^0.5.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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 |
@@ -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"
|
||||
}
|
||||
+21
-4
@@ -12,23 +12,40 @@
|
||||
"check:fix": "biome check --write",
|
||||
"check-types": "turbo run check-types",
|
||||
"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: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:major": "gh workflow run release.yml -f package=both -f bump=major"
|
||||
"release:editor": "gh workflow run release.yml -f package=editor -f bump=patch",
|
||||
"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": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"turbo": "^2.8.15",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "6.0.2",
|
||||
"ultracite": "^7.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"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": [
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
|
||||
@@ -73,9 +73,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"typescript": "5.9.3",
|
||||
"@types/three": "^0.184.0"
|
||||
"@types/three": "^0.184.0",
|
||||
"typescript": "6.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"3d",
|
||||
|
||||
@@ -28,12 +28,13 @@ export interface GridEvent {
|
||||
/** World-space intersection point on the grid plane. */
|
||||
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.
|
||||
* Use this for placing or committing anything that lives inside a building
|
||||
* (walls, slabs, items, etc.).
|
||||
* Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
|
||||
*/
|
||||
localPosition: [number, number, number]
|
||||
faceIndex?: number
|
||||
object: Object3D
|
||||
nativeEvent: ThreeEvent<PointerEvent>
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
|
||||
export type WindowEvent = NodeEvent<WindowNode>
|
||||
export type DoorEvent = NodeEvent<DoorNode>
|
||||
|
||||
// Event suffixes, exported for use in hooks
|
||||
// Event suffixes - exported for use in hooks
|
||||
export const eventSuffixes = [
|
||||
'click',
|
||||
'move',
|
||||
@@ -99,7 +100,7 @@ export interface ThumbnailGenerateEvent {
|
||||
/**
|
||||
* When true, snap levels to their true positions before capturing (for a
|
||||
* 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.
|
||||
*/
|
||||
snapLevels?: boolean
|
||||
@@ -107,8 +108,10 @@ export interface ThumbnailGenerateEvent {
|
||||
|
||||
export interface CameraControlFitSceneEvent {
|
||||
/**
|
||||
* XZ-plane axis-aligned bounds for camera framing. Omitted values let the
|
||||
* listener choose its default framing pose.
|
||||
* XZ-plane axis-aligned bounds of the scene's geometry, computed from the
|
||||
* 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?: {
|
||||
min: [number, number]
|
||||
|
||||
@@ -6,10 +6,7 @@ export function insetPolygonFromCentroid(
|
||||
return polygon.map(([x, z]) => [x, z] as [number, number])
|
||||
}
|
||||
|
||||
const centroid = polygon.reduce(
|
||||
(acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }),
|
||||
{ x: 0, z: 0 },
|
||||
)
|
||||
const centroid = polygon.reduce((acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }), { x: 0, z: 0 })
|
||||
centroid.x /= Math.max(polygon.length, 1)
|
||||
centroid.z /= Math.max(polygon.length, 1)
|
||||
|
||||
@@ -70,7 +67,10 @@ function dedupePolygonPoints(
|
||||
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) {
|
||||
return points.map(([x, z]) => [x, z] as [number, number])
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import {
|
||||
getClampedWallCurveOffset,
|
||||
getWallCurveFrameAt,
|
||||
isCurvedWall,
|
||||
} from '../systems/wall/wall-curve'
|
||||
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
|
||||
CeilingNode,
|
||||
type CeilingNode as CeilingNodeType,
|
||||
SlabNode,
|
||||
type SlabNode as SlabNodeType,
|
||||
type WallNode,
|
||||
} from '../schema'
|
||||
import {
|
||||
getSceneHistoryPauseDepth,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
} from '../store/history-control'
|
||||
import {
|
||||
getClampedWallCurveOffset,
|
||||
getWallCurveFrameAt,
|
||||
isCurvedWall,
|
||||
} from '../systems/wall/wall-curve'
|
||||
import { simplifyClosedPolygon } from './polygon-geometry'
|
||||
|
||||
type Point2D = { x: number; y: number }
|
||||
@@ -147,10 +153,10 @@ function polygonCentroid(points: Point2D[]) {
|
||||
}
|
||||
|
||||
function bboxOf(points: Point2D[]) {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x)
|
||||
@@ -216,7 +222,13 @@ function sampleWallPointsForRoomDetection(
|
||||
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 midPoint = getWallCurveFrameAt(wall, midT).point
|
||||
const deviation = pointLineDistance(midPoint, p0, p1)
|
||||
@@ -361,7 +373,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
|
||||
const signedArea = polygonArea(polygon)
|
||||
if (signedArea <= 0) continue
|
||||
if (signedArea < 0.5 || signedArea > 10000) continue
|
||||
if (signedArea < 0.5 || signedArea > 10_000) continue
|
||||
|
||||
const signature = polygonSignature(polygon)
|
||||
if (faces.some((face) => polygonSignature(face) === signature)) continue
|
||||
@@ -442,10 +454,7 @@ function nextAutoRoomName(
|
||||
return `Room ${maxIndex + 1} ${suffix}`
|
||||
}
|
||||
|
||||
function sameTuplePolygon(
|
||||
current: Array<[number, number]>,
|
||||
next: Array<[number, number]>,
|
||||
) {
|
||||
function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) {
|
||||
return (
|
||||
current.length === next.length &&
|
||||
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)
|
||||
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]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
type MaterialPresetPayload,
|
||||
type MaterialTarget,
|
||||
MaterialTarget as MaterialTargetSchema,
|
||||
} from './schema/material'
|
||||
|
||||
export type MaterialCatalogItem = {
|
||||
@@ -12,6 +14,32 @@ export type MaterialCatalogItem = {
|
||||
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 = [
|
||||
'wood',
|
||||
'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 { ALLOWED_ORIGINS_ENV, AssetUrl } from './asset-url'
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ export const FenceNode = BaseNode.extend({
|
||||
materialPreset: z.string().optional(),
|
||||
start: z.tuple([z.number(), z.number()]),
|
||||
end: z.tuple([z.number(), z.number()]),
|
||||
curveOffset: z.number().optional(),
|
||||
height: z.number().default(1.8),
|
||||
thickness: z.number().default(0.08),
|
||||
curveOffset: z.number().optional(),
|
||||
baseHeight: z.number().default(0.22),
|
||||
postSpacing: z.number().default(2),
|
||||
postSize: z.number().default(0.1),
|
||||
@@ -29,8 +29,8 @@ export const FenceNode = BaseNode.extend({
|
||||
dedent`
|
||||
Fence node - used to represent a fence segment in the building/site 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
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
|
||||
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
|
||||
- color/style: visual appearance options
|
||||
|
||||
@@ -83,6 +83,18 @@ const assetSchema = z.object({
|
||||
// Optional top-down 2D image shown inside the item's footprint on the
|
||||
// floor plan. When present, replaces the default diagonal-cross marker.
|
||||
floorPlanUrl: z.string().optional(),
|
||||
// Where the item came from in the catalog. Used by the editor's items
|
||||
// panel to filter Library / Community / Mine. The server populates it
|
||||
// from `items.userId`: null → 'library', current user → 'mine',
|
||||
// other user → 'community'. Defaults to 'library' when absent (e.g.
|
||||
// the seeded built-in catalog).
|
||||
source: z.enum(['library', 'community', 'mine']).default('library'),
|
||||
// True when the item belongs to the caller and is still in draft status.
|
||||
// The catalog only loads my drafts (other users' drafts are never
|
||||
// published to the catalog). Used so the Community filter can include
|
||||
// *my* published items alongside other users', while leaving drafts
|
||||
// visible only under Mine.
|
||||
isDraft: z.boolean().optional(),
|
||||
src: AssetUrl,
|
||||
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
||||
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
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'
|
||||
|
||||
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
|
||||
export type RoofSurfaceMaterialSpec = {
|
||||
material?: MaterialSchema
|
||||
material?: MaterialSchemaType
|
||||
materialPreset?: string
|
||||
}
|
||||
|
||||
export const RoofNode = BaseNode.extend({
|
||||
id: objectId('roof'),
|
||||
type: nodeType('roof'),
|
||||
material: MaterialSchemaSchema.optional(),
|
||||
material: MaterialSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
topMaterial: MaterialSchemaSchema.optional(),
|
||||
topMaterial: MaterialSchema.optional(),
|
||||
topMaterialPreset: z.string().optional(),
|
||||
edgeMaterial: MaterialSchemaSchema.optional(),
|
||||
edgeMaterial: MaterialSchema.optional(),
|
||||
edgeMaterialPreset: z.string().optional(),
|
||||
wallMaterial: MaterialSchemaSchema.optional(),
|
||||
wallMaterial: MaterialSchema.optional(),
|
||||
wallMaterialPreset: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around Y axis in radians
|
||||
@@ -54,7 +55,8 @@ export function getEffectiveRoofSurfaceMaterial(
|
||||
if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') {
|
||||
return {
|
||||
material: node.topMaterial,
|
||||
materialPreset: typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
|
||||
materialPreset:
|
||||
typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
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'
|
||||
|
||||
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 StairSurfaceMaterialRole = 'railing' | 'tread' | 'side'
|
||||
export type StairSurfaceMaterialSpec = {
|
||||
material?: MaterialSchema
|
||||
material?: MaterialSchemaType
|
||||
materialPreset?: string
|
||||
}
|
||||
|
||||
export const StairNode = BaseNode.extend({
|
||||
id: objectId('stair'),
|
||||
type: nodeType('stair'),
|
||||
material: MaterialSchemaSchema.optional(),
|
||||
material: MaterialSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
railingMaterial: MaterialSchemaSchema.optional(),
|
||||
railingMaterial: MaterialSchema.optional(),
|
||||
railingMaterialPreset: z.string().optional(),
|
||||
treadMaterial: MaterialSchemaSchema.optional(),
|
||||
treadMaterial: MaterialSchema.optional(),
|
||||
treadMaterialPreset: z.string().optional(),
|
||||
sideMaterial: MaterialSchemaSchema.optional(),
|
||||
sideMaterial: MaterialSchema.optional(),
|
||||
sideMaterialPreset: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around Y axis in radians
|
||||
@@ -126,18 +127,26 @@ export function getEffectiveStairSurfaceMaterial(
|
||||
|
||||
const treadFallback = {
|
||||
material: node.treadMaterial,
|
||||
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||
materialPreset:
|
||||
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||
}
|
||||
const sideFallback = {
|
||||
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
|
||||
}
|
||||
|
||||
if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) {
|
||||
if (
|
||||
role === 'side' &&
|
||||
(treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)
|
||||
) {
|
||||
return treadFallback
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ function buildMergedWallAttachmentUpdates(
|
||||
const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[]
|
||||
for (const childId of wallChildren) {
|
||||
const child = nodes[childId]
|
||||
if (!child || !('position' in child) || !Array.isArray(child.position)) {
|
||||
if (!(child && 'position' in child && Array.isArray(child.position))) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -190,10 +190,12 @@ function buildWallMergePlans(
|
||||
})
|
||||
const [primary, secondary] = sortedCandidates
|
||||
if (
|
||||
!primary ||
|
||||
!secondary ||
|
||||
!areWallStylesCompatible(primary, secondary) ||
|
||||
!areWallsCollinearAcrossPoint(primary, secondary, junction)
|
||||
!(
|
||||
primary &&
|
||||
secondary &&
|
||||
areWallStylesCompatible(primary, secondary) &&
|
||||
areWallsCollinearAcrossPoint(primary, secondary, junction)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -275,6 +277,7 @@ export const createNodesAction = (
|
||||
ops.forEach(({ node, parentId }) => {
|
||||
get().markDirty(node.id)
|
||||
if (parentId) get().markDirty(parentId)
|
||||
else if (node.parentId) get().markDirty(node.parentId as AnyNodeId)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
||||
}
|
||||
|
||||
if (!hasInterior && !hasExterior) {
|
||||
if (!(hasInterior || hasExterior)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
||||
return node
|
||||
}
|
||||
@@ -174,7 +174,7 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
||||
return legacyFinish
|
||||
}
|
||||
|
||||
if (!hasRailing && !hasTread && !hasSide) {
|
||||
if (!(hasRailing || hasTread || hasSide)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
||||
return node
|
||||
}
|
||||
@@ -236,7 +236,7 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
|
||||
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
||||
}
|
||||
|
||||
if (!hasTop && !hasEdge && !hasWall) {
|
||||
if (!(hasTop || hasEdge || hasWall)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
||||
return node
|
||||
}
|
||||
@@ -350,7 +350,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
}
|
||||
|
||||
function getNodeChildIds(node: AnyNode): AnyNodeId[] {
|
||||
if (!('children' in node) || !Array.isArray(node.children)) {
|
||||
if (!('children' in node && Array.isArray(node.children))) {
|
||||
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 reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
|
||||
if (normalizedRootNodeIds.length > 0) {
|
||||
@@ -701,8 +708,8 @@ let prevFutureLength = 0
|
||||
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
||||
|
||||
export function clearSceneHistory() {
|
||||
useScene.temporal.getState().clear()
|
||||
resetSceneHistoryPauseDepth()
|
||||
useScene.temporal.getState().clear()
|
||||
prevPastLength = 0
|
||||
prevFutureLength = 0
|
||||
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 type { AnyNode } 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 {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
CeilingNode,
|
||||
LevelNode,
|
||||
SlabNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
} from '../../schema'
|
||||
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
||||
|
||||
type Point2D = [number, number]
|
||||
@@ -36,10 +34,9 @@ type AxisAlignedRect = {
|
||||
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 STAIR_SLAB_OPENING_TIGHTENING = 0
|
||||
const CURVED_STAIR_OPENING_STEP_PADDING = 3
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
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) {
|
||||
const current = points[index]
|
||||
const next = points[(index + 1) % points.length]
|
||||
if (!current || !next) continue
|
||||
if (!(current && next)) continue
|
||||
area += current[0] * next[1] - next[0] * current[1]
|
||||
}
|
||||
return area / 2
|
||||
@@ -426,39 +423,24 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
|
||||
return polygons
|
||||
}
|
||||
|
||||
function getCurvedOpeningStepCount(
|
||||
stair: StairNode,
|
||||
innerRadius: number,
|
||||
outerRadius: number,
|
||||
totalSweep: number,
|
||||
) {
|
||||
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||
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,
|
||||
function getCurvedOpeningPolygon(stair: StairNode): 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 openingSweep =
|
||||
Math.sign(totalSweep || 1) *
|
||||
Math.max(
|
||||
1,
|
||||
Math.ceil(1.8 / treadDepth),
|
||||
Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO),
|
||||
),
|
||||
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
|
||||
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
|
||||
)
|
||||
}
|
||||
|
||||
function buildArcOpeningPolygon(
|
||||
stair: StairNode,
|
||||
innerRadius: number,
|
||||
outerRadius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
): Point2D[] {
|
||||
const sweep = endAngle - startAngle
|
||||
const startAngle = totalSweep / 2 - openingSweep
|
||||
const endAngle = totalSweep / 2
|
||||
const segmentCount = Math.max(
|
||||
10,
|
||||
Math.min(
|
||||
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[] = []
|
||||
@@ -466,7 +448,7 @@ function buildArcOpeningPolygon(
|
||||
|
||||
for (let index = 0; index <= segmentCount; index++) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + sweep * t
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
outerPoints.push(
|
||||
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
|
||||
)
|
||||
@@ -474,8 +456,7 @@ function buildArcOpeningPolygon(
|
||||
|
||||
for (let index = segmentCount; index >= 0; index--) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + sweep * t
|
||||
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
innerPoints.push(
|
||||
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
|
||||
)
|
||||
@@ -484,39 +465,6 @@ function buildArcOpeningPolygon(
|
||||
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[] {
|
||||
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
|
||||
const segmentCount = 48
|
||||
@@ -621,7 +569,7 @@ function getStairOpeningPolygons(
|
||||
}
|
||||
|
||||
if (stair.stairType === 'curved') {
|
||||
return [getCurvedOpeningPolygon(stair, targetElevation)]
|
||||
return [getCurvedOpeningPolygon(stair)]
|
||||
}
|
||||
|
||||
if (stair.stairType === 'spiral') {
|
||||
@@ -775,8 +723,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
||||
|
||||
if (
|
||||
!polygonsEqual(existingHoles, nextHoles) ||
|
||||
!metadataEqual(existingMetadata, nextMetadata)
|
||||
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
||||
) {
|
||||
updates.push({
|
||||
id: slab.id,
|
||||
@@ -826,8 +773,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
||||
|
||||
if (
|
||||
!polygonsEqual(existingHoles, nextHoles) ||
|
||||
!metadataEqual(existingMetadata, nextMetadata)
|
||||
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
||||
) {
|
||||
updates.push({
|
||||
id: ceiling.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Point2D } from './wall-mitering'
|
||||
import type { FenceNode, WallNode } from '../../schema'
|
||||
import type { Point2D } from './wall-mitering'
|
||||
|
||||
const CURVE_EPSILON = 1e-6
|
||||
const DEFAULT_SAMPLE_SEGMENTS = 24
|
||||
@@ -115,7 +115,7 @@ function getWallArcData(wall: WallCurveLike) {
|
||||
}
|
||||
|
||||
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 direction = Math.sign(sagitta) || 1
|
||||
const center = {
|
||||
@@ -183,7 +183,10 @@ export function getWallMidpointHandlePoint(wall: WallCurveLike) {
|
||||
|
||||
export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
|
||||
const count = Math.max(1, segments)
|
||||
return Array.from({ length: count + 1 }, (_, index) => getWallCurveFrameAt(wall, index / count).point)
|
||||
return Array.from(
|
||||
{ length: count + 1 },
|
||||
(_, index) => getWallCurveFrameAt(wall, index / count).point,
|
||||
)
|
||||
}
|
||||
|
||||
export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
|
||||
|
||||
@@ -135,10 +135,7 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
|
||||
return actualJunctions
|
||||
}
|
||||
|
||||
function getWallDirectionFromJunction(
|
||||
wall: WallNode,
|
||||
endType: 'start' | 'end' | 'passthrough',
|
||||
) {
|
||||
function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') {
|
||||
if (endType === 'passthrough') {
|
||||
return {
|
||||
x: wall.end[0] - wall.start[0],
|
||||
@@ -148,9 +145,7 @@ function getWallDirectionFromJunction(
|
||||
|
||||
if (isCurvedWall(wall)) {
|
||||
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
|
||||
return endType === 'start'
|
||||
? frame.tangent
|
||||
: { x: -frame.tangent.x, y: -frame.tangent.y }
|
||||
return endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y }
|
||||
}
|
||||
|
||||
return endType === 'start'
|
||||
@@ -158,18 +153,12 @@ function getWallDirectionFromJunction(
|
||||
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
|
||||
}
|
||||
|
||||
function getWallBoundaryFrame(
|
||||
wall: WallNode,
|
||||
endType: 'start' | 'end',
|
||||
) {
|
||||
function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
|
||||
if (isCurvedWall(wall)) {
|
||||
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
|
||||
return {
|
||||
point: frame.point,
|
||||
tangent:
|
||||
endType === 'start'
|
||||
? frame.tangent
|
||||
: { x: -frame.tangent.x, y: -frame.tangent.y },
|
||||
tangent: endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y },
|
||||
normal: frame.normal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"composite": true,
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -44,24 +44,24 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three-mesh-bvh": "^0.9.8",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
"zustand": "^5.0.11",
|
||||
"three-mesh-bvh": "~0.9.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.7.0",
|
||||
"@pascal-app/viewer": "^0.7.0",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/node": "^22.19.12",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/three": "^0.184.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,12 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
|
||||
)}
|
||||
|
||||
{polygonDraftPolygonPoints && (
|
||||
<polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" />
|
||||
<polygon
|
||||
fill={draftFill}
|
||||
fillOpacity={0.2}
|
||||
points={polygonDraftPolygonPoints}
|
||||
stroke="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{polygonDraftPolylinePoints && (
|
||||
|
||||
@@ -125,7 +125,12 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
|
||||
return (
|
||||
<>
|
||||
{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
|
||||
dashed={measurement.dashedExtensions ?? true}
|
||||
isSelected={measurement.isSelected}
|
||||
|
||||
@@ -396,8 +396,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
<>
|
||||
<polyline
|
||||
fill="none"
|
||||
points={formatSvgPolygonPoints(arrow.polyline)}
|
||||
pointerEvents="none"
|
||||
points={formatSvgPolygonPoints(arrow.polyline)}
|
||||
stroke={straightAccent}
|
||||
strokeWidth="1.15"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -411,8 +411,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
/>
|
||||
<polygon
|
||||
fill={straightAccent}
|
||||
points={formatSvgPolygonPoints(arrow.head)}
|
||||
pointerEvents="none"
|
||||
points={formatSvgPolygonPoints(arrow.head)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
@@ -438,8 +438,6 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
|
||||
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
|
||||
onPointerDown={
|
||||
canFocusStairs && stairSelected
|
||||
? (event) => {
|
||||
@@ -449,6 +447,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
|
||||
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
|
||||
pointerEvents={canSelectStairs ? undefined : 'none'}
|
||||
style={canSelectStairs ? { cursor } : undefined}
|
||||
>
|
||||
@@ -456,8 +456,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
<polygon
|
||||
fill="transparent"
|
||||
key={`${stair.id}:hit:${polygonIndex}`}
|
||||
points={formatSvgPolygonPoints(polygon)}
|
||||
pointerEvents={canSelectStairs ? 'all' : 'none'}
|
||||
points={formatSvgPolygonPoints(polygon)}
|
||||
stroke="transparent"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={hitStrokeWidth}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type CameraControlEvent,
|
||||
type CameraControlFitSceneEvent,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} 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 { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
Executable → Regular
+4
-3
@@ -205,6 +205,7 @@ export function EditorLayoutV2({
|
||||
viewerContent,
|
||||
overlays,
|
||||
}: EditorLayoutV2Props) {
|
||||
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
if (isMobile) {
|
||||
@@ -229,7 +230,7 @@ export function EditorLayoutV2({
|
||||
|
||||
{/* Main content: left column + right column */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{sidebarTabs.length > 0 && (
|
||||
{!isCaptureMode && sidebarTabs.length > 0 && (
|
||||
<LeftColumn
|
||||
renderTabContent={renderTabContent}
|
||||
sidebarOverlay={sidebarOverlay}
|
||||
@@ -238,8 +239,8 @@ export function EditorLayoutV2({
|
||||
)}
|
||||
<RightColumn
|
||||
overlays={overlays}
|
||||
toolbarLeft={viewerToolbarLeft}
|
||||
toolbarRight={viewerToolbarRight}
|
||||
toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
|
||||
toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
|
||||
>
|
||||
{viewerContent}
|
||||
</RightColumn>
|
||||
|
||||
@@ -455,14 +455,15 @@ export const FirstPersonControls = () => {
|
||||
{controllerStart && (
|
||||
<KeyboardControls map={keyboardMap}>
|
||||
<BVHEcctrl
|
||||
ref={controllerRef}
|
||||
key="first-person-controller"
|
||||
acceleration={26}
|
||||
airDragFactor={0.3}
|
||||
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||
colliderMeshes={[world.mesh]}
|
||||
collisionCheckIteration={3}
|
||||
collisionPushBackDamping={0.1}
|
||||
collisionPushBackThreshold={0.001}
|
||||
debug={false}
|
||||
deceleration={30}
|
||||
delay={0}
|
||||
fallGravityFactor={4}
|
||||
floatCheckType="BOTH"
|
||||
@@ -473,13 +474,12 @@ export const FirstPersonControls = () => {
|
||||
floatSpringK={1200}
|
||||
gravity={9.81}
|
||||
jumpVel={6}
|
||||
key="first-person-controller"
|
||||
maxRunSpeed={5.5}
|
||||
maxSlope={1.2}
|
||||
maxWalkSpeed={4}
|
||||
position={controllerStart.position}
|
||||
acceleration={26}
|
||||
airDragFactor={0.3}
|
||||
deceleration={30}
|
||||
ref={controllerRef}
|
||||
/>
|
||||
</KeyboardControls>
|
||||
)}
|
||||
@@ -551,12 +551,12 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
||||
{isLocked && (
|
||||
<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">
|
||||
<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" />
|
||||
<InlineControlHint label="Jump" keyLabel="Space" />
|
||||
<InlineControlHint label="Sprint" keyLabel="Shift" />
|
||||
<InlineControlHint label="Interact" keyLabel="E / R" />
|
||||
<InlineControlHint label="Close" keyLabel="T" />
|
||||
<InlineControlHint keyLabel="Space" label="Jump" />
|
||||
<InlineControlHint keyLabel="Shift" label="Sprint" />
|
||||
<InlineControlHint keyLabel="E / R" label="Interact" />
|
||||
<InlineControlHint keyLabel="T" label="Close" />
|
||||
<div className="h-px w-full bg-border/30" />
|
||||
<span className="text-center text-muted-foreground/60 text-xs">
|
||||
Click to look around
|
||||
@@ -591,7 +591,7 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
||||
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
|
||||
return (
|
||||
<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}
|
||||
</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">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
getGarageVisibleOpeningRatio,
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getGarageVisibleOpeningRatio,
|
||||
isOperationDoorType,
|
||||
sceneRegistry,
|
||||
useInteractive,
|
||||
@@ -97,11 +97,11 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
|
||||
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
||||
if (!node || node.type !== 'door') return false
|
||||
|
||||
if (node.openingKind === 'opening') 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) {
|
||||
@@ -273,9 +273,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
|
||||
}
|
||||
|
||||
const mergedGeometry = mergeGeometries(geometries, false)
|
||||
geometries.forEach((geometry) => {
|
||||
geometry.dispose()
|
||||
})
|
||||
geometries.forEach((geometry) => geometry.dispose())
|
||||
|
||||
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
|
||||
mergedGeometry?.dispose()
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import '../../../three-types'
|
||||
import { TransformControls, useKeyboardControls } from '@react-three/drei'
|
||||
import { useFrame, useThree, type ThreeElements } from '@react-three/fiber'
|
||||
import {
|
||||
Suspense,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import type { ReactNode } from 'react'
|
||||
import { forwardRef, Suspense, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
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 elapsedRef = useRef(0)
|
||||
|
||||
function useIsInsideKeyboardControls() {
|
||||
try {
|
||||
return !!useKeyboardControls()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isInsideKeyboardControls = useIsInsideKeyboardControls()
|
||||
const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null]
|
||||
const [, getKeys] = useKeyboardControls()
|
||||
const presetKeys = {
|
||||
forward: false,
|
||||
backward: false,
|
||||
@@ -222,11 +206,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
const scaledContactRadiusVec = useRef(new THREE.Vector3())
|
||||
const deltaDist = useRef(new THREE.Vector3())
|
||||
const currSlopeAngle = useRef(0)
|
||||
const localMinDistance = useRef(Infinity)
|
||||
const localMinDistance = useRef(Number.POSITIVE_INFINITY)
|
||||
const localClosestPoint = useRef(new THREE.Vector3())
|
||||
const localHitNormal = 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 triHitPoint = 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 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
|
||||
if (idleTime.current > sleepTimeout) isSleeping.current = true
|
||||
} else {
|
||||
@@ -340,7 +330,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
upAxis.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
|
||||
@@ -358,18 +351,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
)
|
||||
currentLinVel.current.add(deltaLinVel.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)
|
||||
}
|
||||
},
|
||||
[acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin],
|
||||
[
|
||||
acceleration,
|
||||
airDragFactor,
|
||||
counterAccFactor,
|
||||
deceleration,
|
||||
maxRunSpeed,
|
||||
maxWalkSpeed,
|
||||
turnSpeed,
|
||||
characterOrigin,
|
||||
],
|
||||
)
|
||||
|
||||
const updateSegmentBBox = useCallback(() => {
|
||||
if (!characterGroupRef.current) return
|
||||
|
||||
characterSegment.current.start.set(0, capsuleLength / 2, 0).add(characterGroupRef.current.position)
|
||||
characterSegment.current.end.set(0, -capsuleLength / 2, 0).add(characterGroupRef.current.position)
|
||||
characterSegment.current.start
|
||||
.set(0, capsuleLength / 2, 0)
|
||||
.add(characterGroupRef.current.position)
|
||||
characterSegment.current.end
|
||||
.set(0, -capsuleLength / 2, 0)
|
||||
.add(characterGroupRef.current.position)
|
||||
|
||||
characterBbox.current
|
||||
.makeEmpty()
|
||||
@@ -394,11 +402,18 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
|
||||
const collisionCheck = useCallback(
|
||||
(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()
|
||||
localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current)
|
||||
localCharacterSegment.current
|
||||
.copy(characterSegment.current)
|
||||
.applyMatrix4(collideInvertMatrix.current)
|
||||
|
||||
scaledContactRadiusVec.current.set(
|
||||
capsuleRadius / contactTempScale.current.x,
|
||||
@@ -445,7 +460,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
contactDepth.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)
|
||||
totalDepth.current += contactDepth.current
|
||||
triangleCount.current += 1
|
||||
@@ -492,14 +510,16 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
|
||||
const floatingCheck = useCallback(
|
||||
(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)
|
||||
floatInvertMatrix.current.copy(originMatrix).invert()
|
||||
floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current)
|
||||
floatNormalMatrix.current.getNormalMatrix(originMatrix)
|
||||
|
||||
localFloatSensorSegment.current.copy(floatSensorSegment.current).applyMatrix4(floatInvertMatrix.current)
|
||||
localFloatSensorSegment.current
|
||||
.copy(floatSensorSegment.current)
|
||||
.applyMatrix4(floatInvertMatrix.current)
|
||||
localFloatSensorBboxExpendPoint.current
|
||||
.copy(floatSensorBboxExpendPoint.current)
|
||||
.applyMatrix4(floatInvertMatrix.current)
|
||||
@@ -517,20 +537,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
|
||||
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
|
||||
|
||||
localMinDistance.current = Infinity
|
||||
localClosestPoint.current.set(Infinity, Infinity, Infinity)
|
||||
localMinDistance.current = Number.POSITIVE_INFINITY
|
||||
localClosestPoint.current.set(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
|
||||
mesh.geometry.boundsTree.shapecast({
|
||||
intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current),
|
||||
intersectsTriangle: (tri) => {
|
||||
tri.closestPointToSegment(localFloatSensorSegment.current, triHitPoint.current, segHitPoint.current)
|
||||
localUpAxis.current.copy(upAxis.current).applyMatrix3(floatNormalInverseMatrix.current).normalize()
|
||||
tri.closestPointToSegment(
|
||||
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.divide(scaledFloatRadiusVec.current)
|
||||
|
||||
const totalLengthSq = deltaHit.current.lengthSq()
|
||||
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))
|
||||
|
||||
if (horizontalLength < 1 && verticalLength < 1) {
|
||||
@@ -560,9 +593,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
const handleFloatingResponse = useCallback(
|
||||
(meshes: THREE.Mesh[], jump: boolean, delta: number) => {
|
||||
if (meshes.length === 0) return
|
||||
let shouldJump = jump
|
||||
|
||||
globalMinDistance.current = Infinity
|
||||
globalClosestPoint.current.set(Infinity, Infinity, Infinity)
|
||||
globalMinDistance.current = Number.POSITIVE_INFINITY
|
||||
globalClosestPoint.current.set(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
floatHitNormal.current.set(0, 1, 0)
|
||||
isOnGround.current = false
|
||||
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.direction.copy(gravityDir.current)
|
||||
const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false)
|
||||
@@ -582,23 +624,28 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
if (hit?.point) {
|
||||
globalClosestPoint.current.copy(hit.point)
|
||||
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()
|
||||
currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current)
|
||||
|
||||
if (currentDistance < floatHeight + capsuleRadius) {
|
||||
isOnGround.current = true
|
||||
jump = false
|
||||
shouldJump = false
|
||||
}
|
||||
|
||||
if (!jump) {
|
||||
if (!shouldJump) {
|
||||
const displacement = floatHeight + capsuleRadius - currentDistance
|
||||
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
|
||||
const springForce = displacement * floatSpringK
|
||||
@@ -608,7 +655,17 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
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(() => {
|
||||
@@ -646,8 +703,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
)
|
||||
|
||||
const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), [])
|
||||
const addLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.add(velocity), [])
|
||||
const setLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.copy(velocity), [])
|
||||
const addLinVel = useCallback(
|
||||
(velocity: THREE.Vector3) => currentLinVel.current.add(velocity),
|
||||
[],
|
||||
)
|
||||
const setLinVel = useCallback(
|
||||
(velocity: THREE.Vector3) => currentLinVel.current.copy(velocity),
|
||||
[],
|
||||
)
|
||||
const setMovement = useCallback((movement: MovementInput) => {
|
||||
if (movement.forward !== undefined) forwardState.current = movement.forward
|
||||
if (movement.backward !== undefined) backwardState.current = movement.backward
|
||||
@@ -682,7 +745,9 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end)
|
||||
standPointRef.current?.position.copy(globalClosestPoint.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))
|
||||
inputDirRef.current?.position.copy(characterSegment.current.end)
|
||||
@@ -698,7 +763,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
if (paused || elapsedRef.current < delay) return
|
||||
|
||||
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 backward = backwardState.current || keys.backward
|
||||
const leftward = leftwardState.current || keys.leftward
|
||||
@@ -740,7 +805,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<group {...props} ref={characterGroupRef} dispose={null}>
|
||||
<group {...props} dispose={null} ref={characterGroupRef}>
|
||||
{debug && (
|
||||
<mesh ref={characterColliderRef}>
|
||||
<capsuleGeometry args={colliderCapsuleArgs} />
|
||||
@@ -777,8 +842,8 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
<octahedronGeometry args={[0.1, 0]} />
|
||||
<meshNormalMaterial />
|
||||
</mesh>
|
||||
<arrowHelper ref={inputDirRef} args={[undefined, undefined, undefined, '#00f']} />
|
||||
<arrowHelper ref={moveDirRef} args={[undefined, undefined, undefined, '#f00']} />
|
||||
<arrowHelper args={[undefined, undefined, undefined, '#00f']} ref={inputDirRef} />
|
||||
<arrowHelper args={[undefined, undefined, undefined, '#f00']} ref={moveDirRef} />
|
||||
<mesh ref={standPointRef}>
|
||||
<octahedronGeometry args={[0.12, 0]} />
|
||||
<meshBasicMaterial color="red" opacity={0.2} transparent />
|
||||
|
||||
Executable → Regular
@@ -4092,7 +4092,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
d={path}
|
||||
fill={isDeleteHovered ? palette.deleteFill : palette.slabFill}
|
||||
fillRule="evenodd"
|
||||
opacity={slabFillOpacity}
|
||||
onClick={
|
||||
canSelectSlabs
|
||||
? (event) => {
|
||||
@@ -4111,9 +4110,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
}
|
||||
onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined}
|
||||
onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined}
|
||||
opacity={slabFillOpacity}
|
||||
pointerEvents={canSelectSlabs ? undefined : 'none'}
|
||||
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
|
||||
stroke="none"
|
||||
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
|
||||
/>
|
||||
{isSelected && !isDeleteHovered ? (
|
||||
<path
|
||||
@@ -4168,7 +4168,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
d={path}
|
||||
fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill}
|
||||
fillRule="evenodd"
|
||||
opacity={ceilingFillOpacity}
|
||||
onClick={
|
||||
canSelectCeilings
|
||||
? (event) => {
|
||||
@@ -4189,9 +4188,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined
|
||||
}
|
||||
onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
|
||||
opacity={ceilingFillOpacity}
|
||||
pointerEvents={canSelectCeilings ? undefined : 'none'}
|
||||
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
|
||||
stroke="none"
|
||||
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
|
||||
/>
|
||||
{isSelected && !isDeleteHovered ? (
|
||||
<path
|
||||
@@ -4298,8 +4298,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
<polygon
|
||||
fill={`url(#${wallSelectionHatchId})`}
|
||||
opacity={1}
|
||||
points={points}
|
||||
pointerEvents="none"
|
||||
points={points}
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
@@ -4427,8 +4427,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
{canSelectGeometry && (
|
||||
<polygon
|
||||
fill="transparent"
|
||||
points={points}
|
||||
pointerEvents="all"
|
||||
points={points}
|
||||
stroke="transparent"
|
||||
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -4538,8 +4538,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
{canSelectGeometry && (
|
||||
<polygon
|
||||
fill="transparent"
|
||||
points={points}
|
||||
pointerEvents="all"
|
||||
points={points}
|
||||
stroke="transparent"
|
||||
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -5218,8 +5218,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
{canSelectGeometry && (
|
||||
<polygon
|
||||
fill="transparent"
|
||||
points={points}
|
||||
pointerEvents="all"
|
||||
points={points}
|
||||
stroke="transparent"
|
||||
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -5290,8 +5290,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
d={foldingPath}
|
||||
fill="none"
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
@@ -5963,6 +5963,12 @@ function FloorplanItemImage({
|
||||
}) {
|
||||
const resolvedUrl = useResolvedAssetUrl(url)
|
||||
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
|
||||
return (
|
||||
<g
|
||||
@@ -6168,6 +6174,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
}
|
||||
points={points}
|
||||
stroke={stroke}
|
||||
strokeOpacity={1}
|
||||
strokeWidth={
|
||||
isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH
|
||||
}
|
||||
@@ -6217,8 +6224,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
<polygon
|
||||
fill={`url(#${wallSelectionHatchId})`}
|
||||
opacity={1}
|
||||
points={points}
|
||||
pointerEvents="none"
|
||||
points={points}
|
||||
/>
|
||||
) : null}
|
||||
{itemDimensionMeasurements.length > 0 ? (
|
||||
@@ -6308,8 +6315,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
<polygon
|
||||
fill={fill}
|
||||
fillOpacity={isDeleteHovered ? 0.82 : 0.92}
|
||||
points={FLOORPLAN_SPAWN_ARROW_POINTS}
|
||||
pointerEvents="none"
|
||||
points={FLOORPLAN_SPAWN_ARROW_POINTS}
|
||||
stroke={stroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={0.055}
|
||||
@@ -7224,6 +7231,11 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
y2={endSvg.y}
|
||||
/>
|
||||
<line
|
||||
onPointerDown={
|
||||
onEdgePointerDown
|
||||
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
|
||||
: undefined
|
||||
}
|
||||
pointerEvents="stroke"
|
||||
stroke="transparent"
|
||||
strokeLinecap="round"
|
||||
@@ -7234,11 +7246,6 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
x2={endSvg.x}
|
||||
y1={startSvg.y}
|
||||
y2={endSvg.y}
|
||||
onPointerDown={
|
||||
onEdgePointerDown
|
||||
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
@@ -9838,7 +9845,7 @@ export function FloorplanPanel() {
|
||||
zoneVertexDragState != null ||
|
||||
isPolygonDraftBuildActive
|
||||
|
||||
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
|
||||
if (!(hasUserAdjustedViewportRef.current || transientFloorplanFit)) {
|
||||
setViewport((current) =>
|
||||
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
|
||||
)
|
||||
@@ -10970,6 +10977,14 @@ export function FloorplanPanel() {
|
||||
}
|
||||
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPendingItemMeshFootprints) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMovingFloorplanNodeRefresh()
|
||||
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
// 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
|
||||
// keyboard rotation during placement updates `useLiveTransforms` but emits
|
||||
@@ -10985,14 +11000,6 @@ export function FloorplanPanel() {
|
||||
return unsubscribe
|
||||
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPendingItemMeshFootprints) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMovingFloorplanNodeRefresh()
|
||||
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
|
||||
return
|
||||
@@ -16132,17 +16139,13 @@ export function FloorplanPanel() {
|
||||
onDuplicate: handleSelectedItemDuplicate,
|
||||
onMove: handleSelectedItemMove,
|
||||
}}
|
||||
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
|
||||
opening={{
|
||||
position: selectedOpeningActionMenuPosition,
|
||||
onDelete: handleSelectedOpeningDelete,
|
||||
onDuplicate: handleSelectedOpeningDuplicate,
|
||||
onMove: handleSelectedOpeningMove,
|
||||
}}
|
||||
spawn={{
|
||||
position: selectedSpawnActionMenuPosition,
|
||||
onDelete: handleSelectedSpawnDelete,
|
||||
onMove: handleSelectedSpawnMove,
|
||||
}}
|
||||
roof={{
|
||||
position: selectedRoofActionMenuPosition,
|
||||
onDelete: handleSelectedRoofDelete,
|
||||
@@ -16157,6 +16160,11 @@ export function FloorplanPanel() {
|
||||
: handleSelectedSlabDelete,
|
||||
onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove,
|
||||
}}
|
||||
spawn={{
|
||||
position: selectedSpawnActionMenuPosition,
|
||||
onDelete: handleSelectedSpawnDelete,
|
||||
onMove: handleSelectedSpawnMove,
|
||||
}}
|
||||
stair={{
|
||||
position: selectedStairActionMenuPosition,
|
||||
onDelete: handleSelectedStairDelete,
|
||||
@@ -16168,7 +16176,6 @@ export function FloorplanPanel() {
|
||||
onDelete: handleSelectedWallDelete,
|
||||
onMove: handleSelectedWallMove,
|
||||
}}
|
||||
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
|
||||
/>
|
||||
|
||||
{referenceScaleDraft && (
|
||||
@@ -16201,7 +16208,7 @@ export function FloorplanPanel() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
</div>
|
||||
<div className="mt-1 font-medium text-sm">
|
||||
@@ -16363,8 +16370,8 @@ export function FloorplanPanel() {
|
||||
<FloorplanGuideLayer
|
||||
activeGuideInteractionGuideId={activeGuideInteractionGuideId}
|
||||
activeGuideInteractionMode={activeGuideInteractionMode}
|
||||
guideUi={guideUi}
|
||||
guides={displayGuides}
|
||||
guideUi={guideUi}
|
||||
isInteractive={canInteractWithGuides}
|
||||
onGuideSelect={handleGuideSelect}
|
||||
onGuideTranslateStart={handleGuideTranslateStart}
|
||||
@@ -16385,6 +16392,8 @@ export function FloorplanPanel() {
|
||||
hoveredSlabId={hoveredSlabId}
|
||||
hoveredWallId={hoveredWallId}
|
||||
isDeleteMode={isDeleteMode}
|
||||
isGuideTraceVisible={isGuideTraceVisible}
|
||||
metersPerUnit={calibratedMetersPerUnit}
|
||||
onCeilingDoubleClick={handleCeilingDoubleClick}
|
||||
onCeilingHoverChange={handleCeilingHoverChange}
|
||||
onCeilingSelect={handleCeilingSelect}
|
||||
@@ -16401,11 +16410,9 @@ export function FloorplanPanel() {
|
||||
openingsPolygons={openingsPolygons}
|
||||
palette={palette}
|
||||
selectedIdSet={selectedIdSet}
|
||||
slabSelectionHatchId={slabSelectionHatchId}
|
||||
slabPolygons={displaySlabPolygons}
|
||||
slabSelectionHatchId={slabSelectionHatchId}
|
||||
unit={unit}
|
||||
metersPerUnit={calibratedMetersPerUnit}
|
||||
isGuideTraceVisible={isGuideTraceVisible}
|
||||
wallPolygons={displayWallPolygons}
|
||||
wallSelectionHatchId={wallSelectionHatchId}
|
||||
/>
|
||||
@@ -16483,8 +16490,8 @@ export function FloorplanPanel() {
|
||||
|
||||
<FloorplanReferenceScaleLayer
|
||||
draft={referenceScaleDraft}
|
||||
guideUi={guideUi}
|
||||
guides={displayGuides}
|
||||
guideUi={guideUi}
|
||||
palette={palette}
|
||||
unit={unit}
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||
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 { useKeyboard } from '../../hooks/use-keyboard'
|
||||
import {
|
||||
@@ -64,6 +65,7 @@ import { Grid } from './grid'
|
||||
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
|
||||
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
|
||||
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_Y = 14
|
||||
const EDITOR_HOVER_STYLES: HoverStyles = {
|
||||
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true },
|
||||
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false },
|
||||
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true },
|
||||
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
|
||||
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
|
||||
'paint-ready': { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true },
|
||||
'paint-disabled': {
|
||||
visibleColor: 0x94_a3b8,
|
||||
hiddenColor: 0x47_5569,
|
||||
visibleColor: 0x94_a3_b8,
|
||||
hiddenColor: 0x47_55_69,
|
||||
strength: 4,
|
||||
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">
|
||||
<section
|
||||
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">
|
||||
{hints.map((hint) => (
|
||||
@@ -753,7 +755,7 @@ function PaintCursorLayer({
|
||||
(activePaintMaterial.material !== undefined ||
|
||||
activePaintMaterial.materialPreset !== undefined),
|
||||
)
|
||||
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
|
||||
const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
|
||||
const icon = 'mdi:format-color-fill'
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -786,16 +788,16 @@ function PaintCursorLayer({
|
||||
const ViewerCanvas = memo(function ViewerCanvas({
|
||||
isVersionPreviewMode,
|
||||
isLoading,
|
||||
isFirstPersonMode,
|
||||
hasLoadedInitialScene,
|
||||
showLoader,
|
||||
isFirstPersonMode,
|
||||
onThumbnailCapture,
|
||||
}: {
|
||||
isVersionPreviewMode: boolean
|
||||
isLoading: boolean
|
||||
isFirstPersonMode: boolean
|
||||
hasLoadedInitialScene: boolean
|
||||
showLoader: boolean
|
||||
isFirstPersonMode: boolean
|
||||
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
||||
}) {
|
||||
const viewMode = useEditor((s) => s.viewMode)
|
||||
@@ -837,7 +839,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [setFloorplanPaneRatio])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
|
||||
@@ -940,7 +942,9 @@ export default function Editor({
|
||||
commandPaletteEmptyAction,
|
||||
}: EditorProps) {
|
||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||
|
||||
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
|
||||
|
||||
const { isLoadingSceneRef } = useAutoSave({
|
||||
onSave,
|
||||
onDirty,
|
||||
@@ -951,8 +955,7 @@ export default function Editor({
|
||||
const [isSceneLoading, setIsSceneLoading] = useState(false)
|
||||
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
|
||||
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
|
||||
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||
|
||||
const sidebarWidth = useSidebarStore((s) => s.width)
|
||||
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||
@@ -970,39 +973,6 @@ export default function Editor({
|
||||
}
|
||||
}, [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)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -1064,6 +1034,42 @@ export default function Editor({
|
||||
|
||||
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 = (
|
||||
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
|
||||
<ExportManager />
|
||||
@@ -1135,21 +1141,24 @@ export default function Editor({
|
||||
navbarSlot={navbarSlot}
|
||||
overlays={
|
||||
<>
|
||||
<FloatingLevelSelector />
|
||||
{!isVersionPreviewMode && (
|
||||
{!isCaptureMode && <FloatingLevelSelector />}
|
||||
{!(isVersionPreviewMode || isCaptureMode) && (
|
||||
<div className="pointer-events-auto">
|
||||
<ActionMenu />
|
||||
</div>
|
||||
)}
|
||||
{!isVersionPreviewMode && (
|
||||
{!(isVersionPreviewMode || isCaptureMode) && (
|
||||
<div className="pointer-events-auto">
|
||||
<PanelManager />
|
||||
</div>
|
||||
)}
|
||||
{!isCaptureMode && (
|
||||
<div className="pointer-events-auto">
|
||||
<HelperManager />
|
||||
</div>
|
||||
)}
|
||||
{viewerBanner}
|
||||
{projectId ? <SnapshotCaptureOverlay projectId={projectId} /> : null}
|
||||
</>
|
||||
}
|
||||
renderTabContent={renderTabContent}
|
||||
@@ -1159,14 +1168,14 @@ export default function Editor({
|
||||
viewerToolbarLeft={viewerToolbarLeft}
|
||||
viewerToolbarRight={viewerToolbarRight}
|
||||
/>
|
||||
<EditorCommands />
|
||||
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
||||
{/* 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>
|
||||
)}
|
||||
<EditorCommands />
|
||||
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
||||
</>
|
||||
)}
|
||||
</PresetsProvider>
|
||||
@@ -1222,6 +1231,12 @@ export default function Editor({
|
||||
<HelperManager />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Executable → Regular
+2
-2
@@ -341,7 +341,7 @@ function applySingleSurfacePaintPreview(
|
||||
if (node.type === 'ceiling') {
|
||||
const root = getRegisteredMesh(node.id)
|
||||
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
|
||||
if (!root || !overlay) return null
|
||||
if (!(root && overlay)) return null
|
||||
|
||||
const previewColor =
|
||||
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
|
||||
@@ -999,7 +999,6 @@ export const SelectionManager = () => {
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'spawn',
|
||||
'window',
|
||||
'door',
|
||||
'zone',
|
||||
@@ -1392,6 +1391,7 @@ export const SelectionManager = () => {
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'spawn',
|
||||
'window',
|
||||
'door',
|
||||
'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'
|
||||
|
||||
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 type { CameraControls } from '@react-three/drei'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
@@ -28,7 +28,6 @@ import { EDITOR_LAYER } from '../../lib/constants'
|
||||
|
||||
const THUMBNAIL_WIDTH = 1920
|
||||
const THUMBNAIL_HEIGHT = 1080
|
||||
const AUTO_SAVE_DELAY = 10_000
|
||||
|
||||
export interface SnapshotCameraData {
|
||||
position: [number, number, number]
|
||||
@@ -49,8 +48,6 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
const mainCamera = useThree((state) => state.camera)
|
||||
const controls = useThree((state) => state.controls) as CameraControls | null
|
||||
const isGenerating = useRef(false)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingAutoRef = useRef(false)
|
||||
const onThumbnailCaptureRef = useRef(onThumbnailCapture)
|
||||
|
||||
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
|
||||
@@ -61,7 +58,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
onThumbnailCaptureRef.current = 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(() => {
|
||||
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
|
||||
cam.layers.disable(EDITOR_LAYER)
|
||||
@@ -75,7 +72,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
if (!mounted) return
|
||||
|
||||
// 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.
|
||||
const scenePass = pass(scene, cam)
|
||||
scenePass.setMRT(
|
||||
@@ -117,15 +114,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
const ao = (denoisePass as any).r
|
||||
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
|
||||
|
||||
// FXAA requires a texture node as input, convertToTexture renders finalOutput
|
||||
// into an intermediate RT so FXAA can sample it with neighbor UV offsets.
|
||||
// FXAA requires a texture node as input; convertToTexture renders finalOutput
|
||||
// into an intermediate RT so FXAA can sample it with neighbour UV offsets.
|
||||
const aaOutput = fxaa(convertToTexture(finalOutput))
|
||||
|
||||
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
|
||||
pipeline.outputNode = aaOutput
|
||||
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.
|
||||
const { width, height } = gl.domElement
|
||||
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
|
||||
@@ -176,7 +173,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
thumbnailCamera.aspect = width / height
|
||||
thumbnailCamera.updateProjectionMatrix()
|
||||
|
||||
// Capture camera data for snapshot storage.
|
||||
// Capture camera data for snapshot storage
|
||||
const pos = mainCamera.position
|
||||
let tgt: [number, number, number] | null = null
|
||||
if (controls && 'getTarget' in controls) {
|
||||
@@ -192,7 +189,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
...(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 restoreLevels: () => void = () => {}
|
||||
if (snapLevels) {
|
||||
@@ -205,7 +202,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
}
|
||||
|
||||
// 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.
|
||||
const restoreNodeVisibility = (() => {
|
||||
const saved = new Map<THREE.Object3D, boolean>()
|
||||
@@ -231,7 +228,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
if (pipelineRef.current && 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) {
|
||||
rt.setSize(width, height)
|
||||
}
|
||||
@@ -248,7 +245,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
|
||||
// Restore level positions, levelMode, and node visibility immediately after the
|
||||
// render, before the async GPU readback.
|
||||
// render — before the async GPU readback.
|
||||
restoreLevels()
|
||||
restoreLevelMode?.()
|
||||
restoreNodeVisibility()
|
||||
@@ -339,6 +336,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
|
||||
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
||||
} else {
|
||||
// Standard: center-crop to 1920×1080 aspect ratio
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
let sx = 0,
|
||||
@@ -364,7 +362,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
if (captureMode !== undefined) cameraData.captureMode = captureMode
|
||||
cameraData.resolution = { w: outW, h: outH }
|
||||
} else {
|
||||
// Fallback: plain render directly to the canvas.
|
||||
// Fallback: plain render directly to the canvas
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
gl.render(scene, thumbnailCamera)
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
@@ -450,10 +448,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
)
|
||||
|
||||
// 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.
|
||||
// - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
|
||||
// their true positions first for a consistent auto-thumbnail angle.
|
||||
// - host-driven auto-save: `{ projectId, snapLevels: true }` — snaps levels
|
||||
// to their true positions first for a consistent auto-thumbnail angle.
|
||||
// The caller owns policy (when to fire, whether the tab is visible).
|
||||
useEffect(() => {
|
||||
if (!onThumbnailCapture) return
|
||||
|
||||
@@ -469,49 +468,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
||||
}, [generate, onThumbnailCapture])
|
||||
|
||||
// OSS adaptation: keep local debounced auto-capture behavior because the
|
||||
// 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.
|
||||
// Go-to-camera: animate camera to a saved snapshot position/target
|
||||
useEffect(() => {
|
||||
const handler = ({
|
||||
position,
|
||||
|
||||
@@ -144,11 +144,11 @@ export function useFloorplanBackgroundPlacement({
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
setCursorPoint(snappedPoint)
|
||||
|
||||
if (!roofDraftStart) {
|
||||
if (roofDraftStart) {
|
||||
clearRoofPlacementDraft()
|
||||
} else {
|
||||
setRoofDraftStart(snappedPoint)
|
||||
setRoofDraftEnd(snappedPoint)
|
||||
} else {
|
||||
clearRoofPlacementDraft()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
Executable → Regular
@@ -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'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>
|
||||
)
|
||||
}
|
||||
+10
-12
@@ -75,7 +75,9 @@ const CeilingSelectionAffordance = ({
|
||||
ceiling: CeilingNode
|
||||
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])
|
||||
|
||||
@@ -110,11 +112,7 @@ const CeilingSelectionAffordance = ({
|
||||
return createPortal(
|
||||
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
||||
{corners.map((corner, index) => (
|
||||
<CornerBracket
|
||||
ceiling={ceiling}
|
||||
corner={corner}
|
||||
key={`${ceiling.id}-corner-${index}`}
|
||||
/>
|
||||
<CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
|
||||
))}
|
||||
</group>,
|
||||
levelObject,
|
||||
@@ -210,11 +208,7 @@ const BracketLeg = ({
|
||||
]
|
||||
|
||||
return (
|
||||
<mesh
|
||||
onClick={onClick}
|
||||
position={position}
|
||||
rotation={[0, angle, 0]}
|
||||
>
|
||||
<mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
|
||||
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
||||
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
||||
</mesh>
|
||||
@@ -234,7 +228,11 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
|
||||
|
||||
const incomingLength = Math.hypot(incomingVector[0], incomingVector[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 {
|
||||
corner,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
|
||||
* Imperatively toggles the Three.js visibility of roof objects based on the
|
||||
* 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
|
||||
* - segments-wrapper group is shown (individual segments visible for editing)
|
||||
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
||||
|
||||
@@ -68,7 +68,7 @@ export const StairEditSystem = () => {
|
||||
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
||||
const isActive = activeStairIds.has(stairId)
|
||||
|
||||
if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
|
||||
if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
|
||||
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
|
||||
|
||||
if (stairNode?.children?.length) {
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -1,13 +1,19 @@
|
||||
'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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
|
||||
function snap(value: number) {
|
||||
return Math.round(value * 2) / 2
|
||||
@@ -202,6 +208,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{/* @ts-ignore */}
|
||||
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
|
||||
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
|
||||
</line>
|
||||
|
||||
@@ -83,8 +83,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
|
||||
const offsetFromMidpoint =
|
||||
-(
|
||||
const offsetFromMidpoint = -(
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
|
||||
@@ -297,7 +297,7 @@ export const FenceTool: React.FC = () => {
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<shapeGeometry />
|
||||
<meshBasicMaterial
|
||||
@@ -338,7 +338,7 @@ function DraftMeasurementLabel({
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
@@ -131,10 +131,12 @@ function getLinkedFenceSnapshots(args: {
|
||||
}
|
||||
|
||||
if (
|
||||
!samePoint(node.start, originalStart) &&
|
||||
!samePoint(node.start, originalEnd) &&
|
||||
!samePoint(node.end, originalStart) &&
|
||||
!samePoint(node.end, originalEnd)
|
||||
!(
|
||||
samePoint(node.start, originalStart) ||
|
||||
samePoint(node.start, originalEnd) ||
|
||||
samePoint(node.end, originalStart) ||
|
||||
samePoint(node.end, originalEnd)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -303,8 +305,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
const hasChanged =
|
||||
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
|
||||
const hasChanged = !(
|
||||
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
|
||||
)
|
||||
|
||||
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
||||
wasCommitted = true
|
||||
@@ -406,7 +409,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
>
|
||||
<div className="translate-y-10">
|
||||
<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
|
||||
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
|
||||
: 'border-border/70 bg-background/90 text-foreground/80'
|
||||
@@ -430,7 +433,7 @@ function EndpointAngleLabel({
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
@@ -17,8 +17,8 @@ import type * as THREE from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { snapFenceDraftPoint } from './fence-drafting'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { snapFenceDraftPoint } from './fence-drafting'
|
||||
|
||||
function samePoint(a: [number, number], b: [number, number]) {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
@@ -50,10 +50,12 @@ function getLinkedFenceSnapshots(args: {
|
||||
}
|
||||
|
||||
if (
|
||||
!samePoint(node.start, originalStart) &&
|
||||
!samePoint(node.start, originalEnd) &&
|
||||
!samePoint(node.end, originalStart) &&
|
||||
!samePoint(node.end, originalEnd)
|
||||
!(
|
||||
samePoint(node.start, originalStart) ||
|
||||
samePoint(node.start, originalEnd) ||
|
||||
samePoint(node.end, originalStart) ||
|
||||
samePoint(node.end, originalEnd)
|
||||
)
|
||||
) {
|
||||
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(
|
||||
updates.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
@@ -275,13 +279,13 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
clearPreviewState()
|
||||
} else {
|
||||
if (wasCommitted) {
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
for (const linkedFence of linkedOriginalsRef.current) {
|
||||
useLiveTransforms.getState().clear(linkedFence.id)
|
||||
}
|
||||
} else {
|
||||
clearPreviewState()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
|
||||
@@ -90,9 +90,7 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
||||
return <>{cursor}</>
|
||||
}
|
||||
|
||||
export const MoveTool: React.FC<{
|
||||
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
|
||||
}> = ({ onSpawnMoved }) => {
|
||||
export const MoveTool: React.FC = () => {
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
|
||||
if (!movingNode) return null
|
||||
@@ -100,15 +98,14 @@ export const MoveTool: React.FC<{
|
||||
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
||||
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||
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 === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
||||
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
||||
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')
|
||||
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
||||
if (movingNode.type === 'spawn')
|
||||
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
|
||||
if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
|
||||
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
||||
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
||||
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.
|
||||
* 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.
|
||||
* Snaps a position to the active grid step, aligning item edges to grid lines.
|
||||
*/
|
||||
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
|
||||
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 {
|
||||
return Math.round(value / step) * step
|
||||
|
||||
@@ -506,21 +506,22 @@ export const itemSurfaceStrategy = {
|
||||
|
||||
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 {
|
||||
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
||||
nodeUpdate: {
|
||||
position: [x, y, z],
|
||||
parentId: surfaceItem.id,
|
||||
rotation: [
|
||||
(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],
|
||||
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
|
||||
},
|
||||
cursorRotationY: ctx.currentCursorRotationY,
|
||||
gridPosition: [x, y, z],
|
||||
|
||||
@@ -130,7 +130,6 @@ export function useDraftNode(): DraftNodeHandle {
|
||||
useScene.getState().updateNode(draft.id, {
|
||||
position: updateProps.position ?? draft.position,
|
||||
rotation: updateProps.rotation ?? draft.rotation,
|
||||
scale: updateProps.scale ?? draft.scale,
|
||||
side: updateProps.side ?? draft.side,
|
||||
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
||||
parentId: parentId as string,
|
||||
|
||||
@@ -20,15 +20,12 @@ import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Box3,
|
||||
BufferGeometry,
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
type LineSegments,
|
||||
Matrix4,
|
||||
type Mesh,
|
||||
type Object3D,
|
||||
PlaneGeometry,
|
||||
Quaternion,
|
||||
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(
|
||||
item: import('@pascal-app/core').ItemNode | null,
|
||||
asset: AssetInput,
|
||||
@@ -366,7 +290,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
)
|
||||
const shiftFreeRef = useRef(false)
|
||||
const previewBoundsSignatureRef = useRef<string | null>(null)
|
||||
const meshPreviewAppliedRef = useRef(false)
|
||||
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
||||
|
||||
// Store config callbacks in refs to avoid re-running effect when they change
|
||||
@@ -503,7 +426,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
useEffect(() => {
|
||||
if (!asset) return
|
||||
useScene.temporal.getState().pause()
|
||||
meshPreviewAppliedRef.current = false
|
||||
|
||||
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
||||
|
||||
@@ -1264,21 +1186,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
window.addEventListener('contextmenu', onContextMenu)
|
||||
|
||||
// ---- 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 fallbackBounds = expandBoundsToGrid(
|
||||
const previewBounds = expandBoundsToGrid(
|
||||
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
||||
asset.attachTo,
|
||||
gridSnapStep,
|
||||
)
|
||||
const previewBounds = draft
|
||||
? expandBoundsToGrid(
|
||||
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
|
||||
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
||||
asset.attachTo,
|
||||
gridSnapStep,
|
||||
)
|
||||
: fallbackBounds
|
||||
updatePreviewGeometry(previewBounds)
|
||||
updateDimensionGuides(previewBounds)
|
||||
|
||||
@@ -1324,7 +1242,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
return () => {
|
||||
tearingDown = true
|
||||
meshPreviewAppliedRef.current = false
|
||||
unsubDraftWatch()
|
||||
// Clear live transform for any remaining draft
|
||||
if (draftNode.current) {
|
||||
@@ -1358,17 +1275,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
useEffect(() => {
|
||||
if (!asset) return
|
||||
const draft = draftNode.current
|
||||
const fallbackBounds = expandBoundsToGrid(
|
||||
const previewBounds = expandBoundsToGrid(
|
||||
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
||||
asset.attachTo,
|
||||
gridSnapStep,
|
||||
)
|
||||
const meshBounds = draft
|
||||
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
|
||||
: null
|
||||
const previewBounds = meshBounds
|
||||
? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
|
||||
: fallbackBounds
|
||||
updatePreviewGeometry(previewBounds)
|
||||
updateDimensionGuides(previewBounds)
|
||||
}, [gridSnapStep, asset, draftNode])
|
||||
@@ -1388,19 +1299,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
if (!draftNode.current) return
|
||||
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||
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)
|
||||
if (asset.attachTo && placementState.current.surface === 'floor') {
|
||||
@@ -1490,22 +1388,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const measurementContent = (
|
||||
<>
|
||||
<lineSegments
|
||||
layers={EDITOR_LAYER}
|
||||
geometry={initialWidthGuideGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={measurementMaterial}
|
||||
ref={measurementWidthRef}
|
||||
renderOrder={998}
|
||||
/>
|
||||
<lineSegments
|
||||
layers={EDITOR_LAYER}
|
||||
geometry={initialDepthGuideGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={measurementMaterial}
|
||||
ref={measurementDepthRef}
|
||||
renderOrder={998}
|
||||
/>
|
||||
<lineSegments
|
||||
layers={EDITOR_LAYER}
|
||||
geometry={initialHeightGuideGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={measurementMaterial}
|
||||
ref={measurementHeightRef}
|
||||
renderOrder={998}
|
||||
|
||||
@@ -158,7 +158,9 @@ export const MoveRoofTool: React.FC<{
|
||||
|
||||
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -211,7 +213,10 @@ export const MoveRoofTool: React.FC<{
|
||||
})
|
||||
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')
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,17 @@ function collectNodeIdsInBounds(bounds: Bounds): 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) {
|
||||
const node = nodes[childId as AnyNodeId]
|
||||
if (!node) continue
|
||||
@@ -240,22 +250,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
||||
if (objectBoundsIntersectsBounds(node.id, bounds)) {
|
||||
result.push(node.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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') {
|
||||
} else if (node.type === 'item') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.category === 'door' || item.asset.category === 'window') continue
|
||||
const xz = getNodeWorldXZ(item.id)
|
||||
|
||||
@@ -145,7 +145,7 @@ export function getSegmentAngleReferenceAtPoint(
|
||||
}
|
||||
|
||||
const projected = getProjectedPointOnSegment(point, segment)
|
||||
if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) {
|
||||
if (!(projected && pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE))) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||
door: DoorTool,
|
||||
item: ItemTool,
|
||||
zone: ZoneTool,
|
||||
spawn: SpawnTool,
|
||||
window: WindowTool,
|
||||
},
|
||||
furnish: {
|
||||
@@ -63,10 +64,9 @@ export const ToolManager: React.FC = () => {
|
||||
const curvingFence = useEditor((state) => state.curvingFence)
|
||||
const editingHole = useEditor((state) => state.editingHole)
|
||||
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 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)
|
||||
|
||||
// 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 handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
|
||||
setSelection({ selectedIds: [nodeId] })
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
|
||||
|
||||
{/* 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' && (
|
||||
<MoveTool onSpawnMoved={handlePlacedNodeSelected} />
|
||||
)}
|
||||
{!movingNode && showBuildTool && tool === 'spawn' && (
|
||||
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
||||
)}
|
||||
{!movingNode && showBuildTool && tool === 'column' && (
|
||||
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
||||
)}
|
||||
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
|
||||
{!movingNode && BuildToolComponent && tool === 'spawn' ? (
|
||||
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
||||
) : !movingNode && showBuildTool && tool === 'column' ? (
|
||||
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
||||
) : !movingNode && BuildToolComponent && tool !== 'column' ? (
|
||||
<BuildToolComponent />
|
||||
) : null}
|
||||
</group>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -81,15 +81,17 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
|
||||
const offsetFromMidpoint =
|
||||
-(
|
||||
const offsetFromMidpoint = -(
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const snappedOffset = shiftPressedRef.current
|
||||
? offsetFromMidpoint
|
||||
: 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 (
|
||||
previousCurveOffsetRef.current !== null &&
|
||||
|
||||
@@ -112,10 +112,12 @@ function getLinkedWallSnapshots(args: {
|
||||
}
|
||||
|
||||
if (
|
||||
!samePoint(node.start, originalStart) &&
|
||||
!samePoint(node.start, originalEnd) &&
|
||||
!samePoint(node.end, originalStart) &&
|
||||
!samePoint(node.end, originalEnd)
|
||||
!(
|
||||
samePoint(node.start, originalStart) ||
|
||||
samePoint(node.start, originalEnd) ||
|
||||
samePoint(node.end, originalStart) ||
|
||||
samePoint(node.end, originalEnd)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -286,8 +288,9 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
const hasChanged =
|
||||
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
|
||||
const hasChanged = !(
|
||||
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
|
||||
)
|
||||
|
||||
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
||||
wasCommitted = true
|
||||
@@ -391,7 +394,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
>
|
||||
<div className="translate-y-10">
|
||||
<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
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
|
||||
: 'border-border bg-background/95 text-muted-foreground'
|
||||
@@ -415,7 +418,7 @@ function EndpointAngleLabel({
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
@@ -63,10 +63,12 @@ function getLinkedWallSnapshots(args: {
|
||||
}
|
||||
|
||||
if (
|
||||
!samePoint(node.start, originalStart) &&
|
||||
!samePoint(node.start, originalEnd) &&
|
||||
!samePoint(node.end, originalStart) &&
|
||||
!samePoint(node.end, originalEnd)
|
||||
!(
|
||||
samePoint(node.start, originalStart) ||
|
||||
samePoint(node.start, originalEnd) ||
|
||||
samePoint(node.end, originalStart) ||
|
||||
samePoint(node.end, originalEnd)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
Executable → Regular
Executable → Regular
+3
-3
@@ -156,6 +156,8 @@ export const WallTool: React.FC = () => {
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const cursorRef = useRef<Group>(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 endingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const buildingState = useRef(0)
|
||||
@@ -166,8 +168,6 @@ export const WallTool: React.FC = () => {
|
||||
let gridPosition: WallPlanPoint = [0, 0]
|
||||
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) => {
|
||||
if (!(cursorRef.current && wallPreviewRef.current)) return
|
||||
|
||||
@@ -324,7 +324,7 @@ function DraftMeasurementLabel({
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
Executable → Regular
+20
-5
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from './../../../lib/constants'
|
||||
import { sfxEmitter } from './../../../lib/sfx-bus'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
@@ -67,6 +68,9 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
|
||||
|
||||
// Select the newly created zone
|
||||
useViewer.getState().setSelection({ zoneId: zone.id })
|
||||
|
||||
// Play structure build sound
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
}
|
||||
|
||||
type PreviewState = {
|
||||
@@ -86,6 +90,7 @@ export const ZoneTool: React.FC = () => {
|
||||
const mainLineRef = useRef<Line>(null!)
|
||||
const closingLineRef = useRef<Line>(null!)
|
||||
const pointsRef = useRef<Array<[number, number]>>([])
|
||||
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||
const levelYRef = useRef(0) // Track current level Y position
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
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
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||
if (lastPoint) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
|
||||
} else {
|
||||
cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
|
||||
const displayPoint = lastPoint
|
||||
? calculateSnapPoint(lastPoint, cursorPosition)
|
||||
: cursorPosition
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
Executable → Regular
Executable → Regular
+7
-1
@@ -104,7 +104,9 @@ export function ControlModes() {
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||
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)
|
||||
|
||||
// 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,
|
||||
// breaking the 2D floorplan (it needs a level to render the SVG).
|
||||
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
|
||||
}
|
||||
@@ -188,6 +192,8 @@ export function ControlModes() {
|
||||
} else {
|
||||
setPhase('furnish')
|
||||
setMode('build')
|
||||
// Auto-switch sidebar to the items panel so the user can pick furniture
|
||||
useEditor.getState().setActiveSidebarPanel('items')
|
||||
}
|
||||
} else if (id === 'zone') {
|
||||
if (getIsActive('zone')) {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
'use client'
|
||||
|
||||
import NextImage from 'next/image'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
import type { CatalogCategory } from './../../../store/use-editor'
|
||||
|
||||
export type FurnishToolConfig = {
|
||||
id: 'item'
|
||||
@@ -12,91 +7,10 @@ export type FurnishToolConfig = {
|
||||
catalogCategory: CatalogCategory
|
||||
}
|
||||
|
||||
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
|
||||
export const furnishTools: FurnishToolConfig[] = [
|
||||
{
|
||||
id: 'item',
|
||||
iconSrc: '/icons/couch.png',
|
||||
label: 'Furniture',
|
||||
catalogCategory: 'furniture',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{ id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' },
|
||||
{ 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'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { AnimatePresence, motion } from 'motion/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 { 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 { cn } from './../../../lib/utils'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { ItemCatalog } from '../item-catalog/item-catalog'
|
||||
import { CameraActions } from './camera-actions'
|
||||
import { ControlModes } from './control-modes'
|
||||
import { FurnishTools } from './furnish-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() {
|
||||
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
||||
@@ -84,84 +87,16 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
<TooltipProvider>
|
||||
<motion.div
|
||||
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',
|
||||
'transition-colors duration-200 ease-out',
|
||||
className,
|
||||
)}
|
||||
layout
|
||||
style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
|
||||
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 */}
|
||||
<AnimatePresence>
|
||||
{phase === 'structure' && mode === 'build' && (
|
||||
@@ -228,14 +163,28 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Control Mode Row - Always visible, centered */}
|
||||
{isMobile ? (
|
||||
<div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
|
||||
{/* Row 1: control modes only */}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<ControlModes />
|
||||
</div>
|
||||
{/* Row 2: grid snap + secondary toggles (orbit + top view hidden) */}
|
||||
<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" />
|
||||
<ViewToggles />
|
||||
<GridSnapControl />
|
||||
<SecondaryToggles />
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<CameraActions />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
Executable → Regular
+19
-31
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
@@ -258,7 +259,7 @@ function GuidesControl() {
|
||||
|
||||
<PopoverContent
|
||||
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"
|
||||
sideOffset={14}
|
||||
>
|
||||
@@ -353,9 +354,9 @@ function GuidesControl() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Grid snap ──────────────────────────────────────────────────────────────
|
||||
// ── Grid snap toggle ────────────────────────────────────────────────────────
|
||||
|
||||
export function GridSnapControl() {
|
||||
function GridSnapControl() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const gridSnapStep = useEditor((state) => state.gridSnapStep)
|
||||
const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
|
||||
@@ -374,20 +375,7 @@ export function GridSnapControl() {
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
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>
|
||||
<Icon height={16} icon="lucide:grid-2x2" width={16} />
|
||||
<span className="mt-1 font-medium text-[9px] leading-none">
|
||||
{formatGridSnapStep(gridSnapStep)}
|
||||
</span>
|
||||
@@ -510,7 +498,7 @@ function ScansControl() {
|
||||
|
||||
<PopoverContent
|
||||
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"
|
||||
sideOffset={14}
|
||||
>
|
||||
@@ -605,6 +593,8 @@ function ScansControl() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Reference floor control ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ReferenceFloorControl() {
|
||||
const showReferenceFloor = useEditor((state) => state.showReferenceFloor)
|
||||
const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor)
|
||||
@@ -694,7 +684,11 @@ function ReferenceFloorControl() {
|
||||
onClick={toggleReferenceFloor}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -728,9 +722,7 @@ function ReferenceFloorControl() {
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{levelName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{index + 1} below
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">{index + 1} below</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -757,24 +749,20 @@ function ReferenceFloorControl() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main ViewToggles ────────────────────────────────────────────────────────
|
||||
// ── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ViewToggles() {
|
||||
export { GridSnapControl }
|
||||
|
||||
export function SecondaryToggles() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Scans (toggle + dropdown) */}
|
||||
<ScansControl />
|
||||
|
||||
{/* Guides (toggle + dropdown) */}
|
||||
<GuidesControl />
|
||||
|
||||
<ReferenceFloorControl />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Secondary toggles for mobile (grid snap + scans + guides)
|
||||
export function SecondaryToggles() {
|
||||
export function ViewToggles() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<GridSnapControl />
|
||||
|
||||
@@ -35,8 +35,8 @@ import {
|
||||
Video,
|
||||
} from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
||||
import { runRedo, runUndo } from '../../../lib/history'
|
||||
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
||||
import { useCommandRegistry } from '../../../store/use-command-registry'
|
||||
import type { StructureTool } from '../../../store/use-editor'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
@@ -294,12 +294,14 @@ export function EditorCommands() {
|
||||
},
|
||||
{
|
||||
id: 'editor.viewer.camera-snapshot',
|
||||
label: 'Camera Snapshot',
|
||||
label: 'Take Snapshot',
|
||||
group: 'Viewer Controls',
|
||||
icon: <Camera className="h-4 w-4" />,
|
||||
keywords: ['camera', 'snapshot', 'capture', 'save', 'view', 'bookmark'],
|
||||
navigate: true,
|
||||
execute: () => navigateTo('camera-view'),
|
||||
execute: () => {
|
||||
setOpen(false)
|
||||
useEditor.getState().setCaptureMode(true)
|
||||
},
|
||||
},
|
||||
|
||||
// ── View ─────────────────────────────────────────────────────────────
|
||||
|
||||
Executable → Regular
+4
-255
@@ -27,15 +27,13 @@ interface CommandPaletteStore {
|
||||
setInputValue: (value: string) => void
|
||||
navigateTo: (page: string) => void
|
||||
goBack: () => void
|
||||
cameraScope: { nodeId: string; label: string } | null
|
||||
setCameraScope: (scope: { nodeId: string; label: string } | null) => void
|
||||
}
|
||||
|
||||
export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
|
||||
open: false,
|
||||
setOpen: (open) => {
|
||||
set({ open })
|
||||
if (!open) set({ pages: [], inputValue: '', cameraScope: null, mode: 'command' })
|
||||
if (!open) set({ pages: [], inputValue: '', mode: 'command' })
|
||||
},
|
||||
mode: 'command',
|
||||
setMode: (mode) => set({ mode }),
|
||||
@@ -44,12 +42,8 @@ export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
|
||||
setInputValue: (value) => set({ inputValue: value }),
|
||||
navigateTo: (page) => set((s) => ({ pages: [...s.pages, page], inputValue: '' })),
|
||||
goBack: () => {
|
||||
const { pages } = get()
|
||||
if (pages[pages.length - 1] === 'camera-scope') set({ cameraScope: null })
|
||||
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',
|
||||
'rename-level': 'Rename Level',
|
||||
'goto-level': 'Go to Level',
|
||||
'camera-view': 'Camera Snapshot',
|
||||
'camera-scope': '',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,19 +188,8 @@ function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) {
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) {
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
mode,
|
||||
setMode,
|
||||
pages,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
navigateTo,
|
||||
goBack,
|
||||
cameraScope,
|
||||
setCameraScope,
|
||||
} = useCommandPalette()
|
||||
const { open, setOpen, mode, setMode, pages, inputValue, setInputValue, navigateTo, goBack } =
|
||||
useCommandPalette()
|
||||
|
||||
const [meta, setMeta] = useState('⌘')
|
||||
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
|
||||
useEffect(() => {
|
||||
setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl')
|
||||
@@ -279,7 +255,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
// Camera snapshot helpers (used by sub-pages registered via EditorCommands)
|
||||
const confirmRename = () => {
|
||||
if (!(activeLevelId && inputValue.trim())) return
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -363,9 +315,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
||||
onClick={goBack}
|
||||
type="button"
|
||||
>
|
||||
{page === 'camera-scope'
|
||||
? (cameraScope?.label ?? 'Snapshot')
|
||||
: (PAGE_LABEL[page] ?? views.get(page)?.label ?? page)}
|
||||
{PAGE_LABEL[page] ?? views.get(page)?.label ?? page}
|
||||
</button>
|
||||
)}
|
||||
<Command.Input
|
||||
@@ -500,207 +450,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
||||
</Command.Item>
|
||||
</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>
|
||||
|
||||
{/* Footer hint */}
|
||||
|
||||
Executable → Regular
+8
-5
@@ -4,9 +4,11 @@ import {
|
||||
getCatalogMaterialById,
|
||||
getLibraryMaterialIdFromRef,
|
||||
getMaterialsForCategory,
|
||||
getMaterialsForTarget,
|
||||
MATERIAL_CATEGORIES,
|
||||
toLibraryMaterialRef,
|
||||
type MaterialSchema,
|
||||
type MaterialTarget,
|
||||
toLibraryMaterialRef,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
@@ -17,6 +19,8 @@ type MaterialPickerProps = {
|
||||
onChange?: (material: MaterialSchema) => void
|
||||
onSelectMaterialPreset?: (materialPreset: string) => void
|
||||
disabled?: boolean
|
||||
nodeType?: MaterialTarget
|
||||
hideSideControl?: boolean
|
||||
}
|
||||
|
||||
export function MaterialPicker({
|
||||
@@ -48,8 +52,7 @@ export function MaterialPicker({
|
||||
return
|
||||
}
|
||||
|
||||
const catalogId =
|
||||
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
|
||||
const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
|
||||
const selectedCatalogEntry = getCatalogMaterialById(catalogId)
|
||||
if (selectedCatalogEntry?.category) {
|
||||
setSelectedCategory(selectedCatalogEntry.category)
|
||||
@@ -177,7 +180,7 @@ export function MaterialPicker({
|
||||
title={item.label}
|
||||
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 ? (
|
||||
<img
|
||||
alt={item.label}
|
||||
@@ -193,7 +196,7 @@ export function MaterialPicker({
|
||||
))}
|
||||
{selectedCategory === 'other' && onChange ? (
|
||||
<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
|
||||
? 'border-blue-500 ring-2 ring-blue-500/30'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
|
||||
Executable → Regular
+1
-1
@@ -162,7 +162,7 @@ function LevelRow({
|
||||
{...dragHandleProps}
|
||||
aria-label={`Reorder ${getLevelDisplayLabel(level)}`}
|
||||
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',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { BuildingHelper } from './building-helper'
|
||||
import { CeilingHelper } from './ceiling-helper'
|
||||
@@ -12,6 +13,10 @@ export function HelperManager() {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const tool = useEditor((s) => s.tool)
|
||||
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.type === 'building') return <BuildingHelper showRotate />
|
||||
|
||||
Executable → Regular
+1742
-315
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 { 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 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(() => {
|
||||
const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
|
||||
if (!isCurrentItemInCategory && categoryItems.length > 0) {
|
||||
@@ -27,58 +67,60 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||
}
|
||||
}, [categoryItems, selectedItem?.src, setSelectedItem])
|
||||
|
||||
// Get attachment icon based on attachTo type
|
||||
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
|
||||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||||
return '/icons/wall.png'
|
||||
}
|
||||
if (attachTo === 'ceiling') {
|
||||
return '/icons/ceiling.png'
|
||||
}
|
||||
if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
|
||||
if (attachTo === 'ceiling') return '/icons/ceiling.png'
|
||||
return null
|
||||
}
|
||||
|
||||
if (filteredItems.length === 0 && emptyState) {
|
||||
return <>{emptyState}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
|
||||
{categoryItems.map((item, index) => {
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))' }}
|
||||
>
|
||||
{leadingTile}
|
||||
{filteredItems.map((item, index) => {
|
||||
const isSelected = selectedItem?.src === item?.src
|
||||
const attachmentIcon = getAttachmentIcon(item?.attachTo)
|
||||
return (
|
||||
<Tooltip key={index}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'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',
|
||||
'group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent',
|
||||
isSelected && 'bg-sidebar-accent ring-2 ring-primary-foreground',
|
||||
)}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setSelectedItem(item)
|
||||
setTool('item')
|
||||
setMode('build')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Image
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg">
|
||||
<img
|
||||
alt={item.name}
|
||||
className="rounded-lg object-cover"
|
||||
fill
|
||||
className="h-full w-full object-cover"
|
||||
loading="eager"
|
||||
sizes="56px"
|
||||
src={resolveCdnUrl(item.thumbnail) || ''}
|
||||
/>
|
||||
{attachmentIcon && (
|
||||
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
|
||||
<Image
|
||||
<div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
|
||||
<img
|
||||
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
|
||||
className="h-4 w-4"
|
||||
height={16}
|
||||
src={attachmentIcon}
|
||||
width={16}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs" side="top">
|
||||
</div>
|
||||
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
|
||||
{item.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import type { LevelNode } from '@pascal-app/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { LevelDuplicatePreset } from '../../lib/level-duplication'
|
||||
import { cn } from '../../lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -69,9 +69,7 @@ export function LevelDuplicateDialog({
|
||||
<DialogContent className="sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate Level</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose what to copy from {getLevelLabel(level)}.
|
||||
</DialogDescription>
|
||||
<DialogDescription>Choose what to copy from {getLevelLabel(level)}.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-2">
|
||||
@@ -95,7 +93,7 @@ export function LevelDuplicateDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<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)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
Executable → Regular
+2
-3
@@ -120,9 +120,8 @@ export function CeilingPanel() {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
const current = polygon[i]
|
||||
const next = polygon[j]
|
||||
if (!(current && next)) continue
|
||||
const current = polygon[i]!
|
||||
const next = polygon[j]!
|
||||
area += current[0] * next[1]
|
||||
area -= next[0] * current[1]
|
||||
}
|
||||
|
||||
@@ -70,10 +70,12 @@ const 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, preset]) => ({
|
||||
value: value as ColumnProportionPresetId,
|
||||
label: preset.label,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
@@ -201,7 +203,12 @@ export function ColumnPanel() {
|
||||
const shaftProfile = node.shaftProfile ?? 'straight'
|
||||
|
||||
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">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
@@ -223,7 +230,9 @@ export function ColumnPanel() {
|
||||
<PanelSection title="Shape">
|
||||
<select
|
||||
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}
|
||||
>
|
||||
<option value="round">Round</option>
|
||||
@@ -313,7 +322,9 @@ export function ColumnPanel() {
|
||||
<PanelSection title="Shaft">
|
||||
<select
|
||||
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}
|
||||
>
|
||||
<option value="straight">Straight</option>
|
||||
@@ -487,10 +498,22 @@ export function ColumnPanel() {
|
||||
? {}
|
||||
: {
|
||||
capitalHeight: Math.max(node.capitalHeight, 0.12),
|
||||
capitalTierCount: capitalStyle === 'stepped' ? Math.max(node.capitalTierCount ?? 3, 3) : 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,
|
||||
capitalTierCount:
|
||||
capitalStyle === 'stepped'
|
||||
? Math.max(node.capitalTierCount ?? 3, 3)
|
||||
: 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),
|
||||
baseTierCount: baseStyle === 'stepped-square' ? Math.max(node.baseTierCount ?? 3, 3) : node.baseTierCount,
|
||||
baseWidthScale: Math.max(node.baseWidthScale ?? 1.24, 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,
|
||||
baseTierCount:
|
||||
baseStyle === 'stepped-square'
|
||||
? Math.max(node.baseTierCount ?? 3, 3)
|
||||
: node.baseTierCount,
|
||||
baseWidthScale: Math.max(
|
||||
node.baseWidthScale ?? 1.24,
|
||||
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,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
|
||||
Executable → Regular
+12
-5
@@ -12,8 +12,8 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
@@ -148,7 +148,13 @@ export function DoorPanel() {
|
||||
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
|
||||
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 = {
|
||||
id: selectedId as AnyNodeId,
|
||||
key,
|
||||
@@ -333,7 +339,8 @@ export function DoorPanel() {
|
||||
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
|
||||
const isOpening = node.openingKind === 'opening'
|
||||
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 openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
|
||||
const cornerRadius = node.cornerRadius ?? 0.15
|
||||
@@ -578,7 +585,8 @@ export function DoorPanel() {
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 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}
|
||||
key={option.value}
|
||||
@@ -1267,7 +1275,6 @@ export function DoorPanel() {
|
||||
</div>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
@@ -86,10 +85,6 @@ export function FencePanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
||||
|
||||
const length = getWallCurveLength(node)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { Input } from '../primitives/input'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { Input } from '../primitives/input'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
function buildDefaultCustomMaterial() {
|
||||
@@ -53,11 +52,7 @@ export function PaintPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
onClose={() => setPaintPanelOpen(false)}
|
||||
title="Material"
|
||||
width={320}
|
||||
>
|
||||
<PanelWrapper onClose={() => setPaintPanelOpen(false)} title="Material" width={320}>
|
||||
<PanelSection title="Custom Material">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
@@ -78,42 +73,72 @@ export function PaintPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||
Surface
|
||||
<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]">
|
||||
Roughness
|
||||
</label>
|
||||
<div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
|
||||
<SliderControl
|
||||
label="Roughness"
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{currentProps.roughness.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(roughness) => updateCustomMaterial({ roughness })}
|
||||
precision={2}
|
||||
onChange={(e) =>
|
||||
updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })
|
||||
}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.roughness}
|
||||
/>
|
||||
<SliderControl
|
||||
label="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]">
|
||||
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={(metalness) => updateCustomMaterial({ metalness })}
|
||||
precision={2}
|
||||
onChange={(e) =>
|
||||
updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })
|
||||
}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.metalness}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Opacity"
|
||||
</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={(opacity) =>
|
||||
onChange={(e) => {
|
||||
const opacity = Number.parseFloat(e.target.value)
|
||||
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
|
||||
}
|
||||
precision={2}
|
||||
}}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.opacity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||
|
||||
Executable → Regular
+3
-32
@@ -92,6 +92,8 @@ function panelForType(type: string | null) {
|
||||
return <StairSegmentPanel />
|
||||
case 'slab':
|
||||
return <SlabPanel />
|
||||
case 'spawn':
|
||||
return <SpawnPanel />
|
||||
case 'ceiling':
|
||||
return <CeilingPanel />
|
||||
case 'column':
|
||||
@@ -238,36 +240,5 @@ export function PanelManager() {
|
||||
}
|
||||
|
||||
// Show appropriate panel based on selected node type
|
||||
if (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
|
||||
return panelForType(selectedNodeType)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,21 @@ import {
|
||||
type AnyNode,
|
||||
type GuideNode,
|
||||
loadAssetUrl,
|
||||
saveAsset,
|
||||
type ScanNode,
|
||||
saveAsset,
|
||||
useScene,
|
||||
} 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 { guideEmitter } from '../../../lib/guide-events'
|
||||
import { getGuideImageName } from '../../../lib/local-guide-image'
|
||||
@@ -32,7 +42,9 @@ function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
|
||||
export function ReferencePanel() {
|
||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||
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 setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
|
||||
const clearGuideUi = useEditor((s) => s.clearGuideUi)
|
||||
@@ -77,11 +89,14 @@ export function ReferencePanel() {
|
||||
|
||||
try {
|
||||
const assetUrl = await saveAsset(file)
|
||||
updateNode(selectedReferenceId as AnyNode['id'], {
|
||||
updateNode(
|
||||
selectedReferenceId as AnyNode['id'],
|
||||
{
|
||||
name: getGuideImageName(file.name),
|
||||
url: assetUrl,
|
||||
scaleReference: null,
|
||||
} as Partial<GuideNode>)
|
||||
} as Partial<GuideNode>,
|
||||
)
|
||||
setGuideScaleReferenceVisible(selectedReferenceId, true)
|
||||
} catch {
|
||||
setReplaceError('Could not replace that image.')
|
||||
@@ -138,7 +153,7 @@ export function ReferencePanel() {
|
||||
const isScan = node.type === 'scan'
|
||||
const guideLocked = !isScan && guideUi?.locked === true
|
||||
const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
|
||||
const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null
|
||||
const scaleStatus = isScan ? null : getScaleStatus(node, scaleReferenceVisible)
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
@@ -165,16 +180,16 @@ export function ReferencePanel() {
|
||||
|
||||
<ActionGroup>
|
||||
<ActionButton
|
||||
disabled={isReplacing}
|
||||
icon={<Upload className="h-3.5 w-3.5" />}
|
||||
label={isReplacing ? 'Replacing...' : 'Replace'}
|
||||
onClick={() => replaceInputRef.current?.click()}
|
||||
disabled={isReplacing}
|
||||
/>
|
||||
<ActionButton
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
label="Delete"
|
||||
onClick={handleDeleteGuide}
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
/>
|
||||
</ActionGroup>
|
||||
|
||||
@@ -232,16 +247,16 @@ export function ReferencePanel() {
|
||||
|
||||
<ActionGroup>
|
||||
<ActionButton
|
||||
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
|
||||
disabled={!node.scaleReference}
|
||||
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
|
||||
onClick={() => {
|
||||
if (!node.scaleReference) return
|
||||
setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Clear Scale"
|
||||
disabled={!node.scaleReference}
|
||||
label="Clear Scale"
|
||||
onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
|
||||
/>
|
||||
</ActionGroup>
|
||||
|
||||
Executable → Regular
+52
-2
@@ -3,21 +3,25 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveRoofSurfaceMaterial,
|
||||
type MaterialSchema,
|
||||
type RoofNode,
|
||||
type RoofSurfaceMaterialRole,
|
||||
RoofNode as RoofNodeSchema,
|
||||
type RoofSegmentNode,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
type RoofSurfaceMaterialRole,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
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 { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
@@ -28,6 +32,7 @@ export function RoofPanel() {
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
||||
@@ -50,6 +55,35 @@ export function RoofPanel() {
|
||||
[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(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -225,6 +259,22 @@ export function RoofPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -87,7 +87,7 @@ export function SpawnPanel() {
|
||||
|
||||
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)
|
||||
|
||||
return (
|
||||
@@ -97,7 +97,9 @@ export function SpawnPanel() {
|
||||
label="X"
|
||||
max={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}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
@@ -107,7 +109,9 @@ export function SpawnPanel() {
|
||||
label="Y"
|
||||
max={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}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
@@ -117,7 +121,9 @@ export function SpawnPanel() {
|
||||
label="Z"
|
||||
max={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}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
|
||||
@@ -3,25 +3,31 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveStairSurfaceMaterial,
|
||||
type LevelNode,
|
||||
type MaterialSchema,
|
||||
type StairNode,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairRailingMode,
|
||||
type StairSlabOpeningMode,
|
||||
type StairTopLandingMode,
|
||||
type StairType,
|
||||
type StairSegmentNode,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
type StairSlabOpeningMode,
|
||||
type StairSurfaceMaterialRole,
|
||||
type StairTopLandingMode,
|
||||
type StairType,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
@@ -59,6 +65,7 @@ export function StairPanel() {
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
||||
@@ -89,6 +96,35 @@ export function StairPanel() {
|
||||
[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(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -222,11 +258,11 @@ export function StairPanel() {
|
||||
/>
|
||||
|
||||
<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
|
||||
</div>
|
||||
<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 })}
|
||||
value={resolvedFromLevelId ?? ''}
|
||||
>
|
||||
@@ -239,11 +275,11 @@ export function StairPanel() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
</div>
|
||||
<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 })}
|
||||
value={resolvedToLevelId ?? ''}
|
||||
>
|
||||
@@ -262,7 +298,7 @@ export function StairPanel() {
|
||||
/>
|
||||
|
||||
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
|
||||
<SliderControl
|
||||
<MetricControl
|
||||
label="Opening Offset"
|
||||
max={0.5}
|
||||
min={0}
|
||||
@@ -308,7 +344,7 @@ export function StairPanel() {
|
||||
|
||||
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
|
||||
<PanelSection title="Geometry">
|
||||
<SliderControl
|
||||
<MetricControl
|
||||
label="Width"
|
||||
max={10}
|
||||
min={0.4}
|
||||
@@ -318,7 +354,7 @@ export function StairPanel() {
|
||||
unit="m"
|
||||
value={Math.round((node.width ?? 1) * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
<MetricControl
|
||||
label="Rise"
|
||||
max={10}
|
||||
min={0.2}
|
||||
@@ -328,7 +364,7 @@ export function StairPanel() {
|
||||
unit="m"
|
||||
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
<MetricControl
|
||||
label="Steps"
|
||||
max={32}
|
||||
min={2}
|
||||
@@ -346,7 +382,7 @@ export function StairPanel() {
|
||||
/>
|
||||
)}
|
||||
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
|
||||
<SliderControl
|
||||
<MetricControl
|
||||
label="Thickness"
|
||||
max={1}
|
||||
min={0.02}
|
||||
@@ -357,7 +393,7 @@ export function StairPanel() {
|
||||
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
<SliderControl
|
||||
<MetricControl
|
||||
label="Inner Radius"
|
||||
max={10}
|
||||
min={node.stairType === 'spiral' ? 0.05 : 0.2}
|
||||
@@ -385,7 +421,7 @@ export function StairPanel() {
|
||||
value={node.topLandingMode ?? 'none'}
|
||||
/>
|
||||
{(node.topLandingMode ?? 'none') === 'integrated' && (
|
||||
<SliderControl
|
||||
<MetricControl
|
||||
label="Top Landing"
|
||||
max={5}
|
||||
min={0.3}
|
||||
@@ -520,6 +556,22 @@ export function StairPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Executable → Regular
+97
-1
@@ -4,28 +4,60 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getClampedWallCurveOffset,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getMaxWallCurveOffset,
|
||||
getWallCurveLength,
|
||||
getWallSurfaceMaterialSignature,
|
||||
type MaterialSchema,
|
||||
normalizeWallCurveOffset,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WallSurfaceSide,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Spline } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
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() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||
@@ -56,6 +88,35 @@ export function WallPanel() {
|
||||
[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(
|
||||
(newLength: number) => {
|
||||
if (!node || newLength <= 0) return
|
||||
@@ -79,6 +140,24 @@ export function WallPanel() {
|
||||
[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(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -160,6 +239,23 @@ export function WallPanel() {
|
||||
)}
|
||||
</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">
|
||||
<ActionGroup>
|
||||
<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
Reference in New Issue
Block a user