@pascal-app/editor 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +6 -6
  2. package/src/components/editor/custom-camera-controls.tsx +2 -1
  3. package/src/components/editor/editor-layout-v2.tsx +4 -3
  4. package/src/components/editor/first-person/build-collider-world.ts +5 -7
  5. package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
  6. package/src/components/editor/first-person-controls.tsx +11 -11
  7. package/src/components/editor/floating-action-menu.tsx +0 -0
  8. package/src/components/editor/floorplan-panel.tsx +44 -37
  9. package/src/components/editor/index.tsx +68 -53
  10. package/src/components/editor/selection-manager.tsx +2 -2
  11. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  12. package/src/components/editor/thumbnail-generator.tsx +18 -61
  13. package/src/components/editor/use-floorplan-background-placement.ts +3 -3
  14. package/src/components/editor/wall-measurement-label.tsx +0 -0
  15. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  16. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
  17. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
  18. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  19. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  20. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  21. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  22. package/src/components/systems/zone/zone-system.tsx +0 -0
  23. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  24. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  25. package/src/components/tools/fence/fence-tool.tsx +2 -2
  26. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
  27. package/src/components/tools/fence/move-fence-tool.tsx +13 -9
  28. package/src/components/tools/item/move-tool.tsx +3 -6
  29. package/src/components/tools/item/placement-math.ts +2 -4
  30. package/src/components/tools/item/placement-strategies.ts +11 -10
  31. package/src/components/tools/item/use-draft-node.ts +0 -1
  32. package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
  33. package/src/components/tools/roof/move-roof-tool.tsx +7 -2
  34. package/src/components/tools/select/box-select-tool.tsx +12 -17
  35. package/src/components/tools/shared/segment-angle.ts +1 -1
  36. package/src/components/tools/tool-manager.tsx +12 -12
  37. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  38. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
  39. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  40. package/src/components/tools/wall/wall-drafting.ts +0 -0
  41. package/src/components/tools/wall/wall-tool.tsx +3 -3
  42. package/src/components/tools/zone/zone-tool.tsx +20 -5
  43. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  44. package/src/components/ui/action-menu/control-modes.tsx +7 -1
  45. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  46. package/src/components/ui/action-menu/index.tsx +35 -86
  47. package/src/components/ui/action-menu/view-toggles.tsx +19 -31
  48. package/src/components/ui/command-palette/editor-commands.tsx +6 -4
  49. package/src/components/ui/command-palette/index.tsx +4 -255
  50. package/src/components/ui/controls/material-picker.tsx +8 -5
  51. package/src/components/ui/floating-level-selector.tsx +1 -1
  52. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  53. package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
  54. package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
  55. package/src/components/ui/level-duplicate-dialog.tsx +3 -5
  56. package/src/components/ui/panels/ceiling-panel.tsx +2 -3
  57. package/src/components/ui/panels/column-panel.tsx +62 -18
  58. package/src/components/ui/panels/door-panel.tsx +272 -265
  59. package/src/components/ui/panels/fence-panel.tsx +0 -5
  60. package/src/components/ui/panels/paint-panel.tsx +66 -41
  61. package/src/components/ui/panels/panel-manager.tsx +3 -32
  62. package/src/components/ui/panels/reference-panel.tsx +28 -13
  63. package/src/components/ui/panels/roof-panel.tsx +52 -2
  64. package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
  65. package/src/components/ui/panels/slab-panel.tsx +0 -0
  66. package/src/components/ui/panels/spawn-panel.tsx +10 -4
  67. package/src/components/ui/panels/stair-panel.tsx +66 -14
  68. package/src/components/ui/panels/wall-panel.tsx +97 -1
  69. package/src/components/ui/panels/window-panel.tsx +13 -5
  70. package/src/components/ui/primitives/number-input.tsx +1 -1
  71. package/src/components/ui/primitives/sidebar.tsx +0 -0
  72. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  73. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  74. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  75. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  76. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  77. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  78. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  79. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
  80. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
  81. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  82. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  83. package/src/components/ui/slider.tsx +1 -1
  84. package/src/components/viewer-overlay.tsx +0 -0
  85. package/src/components/viewer-zone-system.tsx +0 -0
  86. package/src/hooks/use-auto-save.ts +14 -0
  87. package/src/hooks/use-keyboard.ts +10 -0
  88. package/src/index.tsx +8 -1
  89. package/src/lib/level-duplication.test.ts +0 -2
  90. package/src/lib/level-duplication.ts +1 -1
  91. package/src/lib/material-paint.ts +1 -1
  92. package/src/lib/roof-duplication.ts +1 -1
  93. package/src/lib/scene-bounds.ts +1 -1
  94. package/src/lib/scene.ts +0 -0
  95. package/src/lib/sfx-bus.ts +2 -0
  96. package/src/lib/sfx-player.ts +5 -5
  97. package/src/lib/stair-duplication.ts +2 -2
  98. package/src/store/use-editor.tsx +27 -59
  99. package/tsconfig.json +2 -1
  100. package/src/components/feedback-dialog.tsx +0 -265
  101. package/src/components/pascal-radio.tsx +0 -280
  102. package/src/components/preview-button.tsx +0 -16
  103. package/src/components/ui/viewer-toolbar.tsx +0 -436
@@ -13,13 +13,53 @@ import { cn } from './../../../lib/utils'
13
13
  import useEditor, { type CatalogCategory } from './../../../store/use-editor'
14
14
  import { CATALOG_ITEMS } from './catalog-items'
15
15
 
16
- export function ItemCatalog({ category }: { category: CatalogCategory }) {
16
+ export function ItemCatalog({
17
+ category,
18
+ items: itemsOverride,
19
+ activePlacementTag = null,
20
+ activeFunctionalTag = null,
21
+ search = '',
22
+ overrideItems,
23
+ leadingTile,
24
+ emptyState,
25
+ }: {
26
+ category: CatalogCategory
27
+ items?: AssetInput[]
28
+ activePlacementTag?: string | null
29
+ activeFunctionalTag?: string | null
30
+ search?: string
31
+ /** When set, bypasses all filtering and displays these items directly (used for server search results) */
32
+ overrideItems?: AssetInput[]
33
+ /** Rendered as the first grid cell, always visible when there are items. */
34
+ leadingTile?: React.ReactNode
35
+ /** Rendered when there are no items to show. Replaces the empty grid. */
36
+ emptyState?: React.ReactNode
37
+ }) {
17
38
  const selectedItem = useEditor((state) => state.selectedItem)
18
39
  const setSelectedItem = useEditor((state) => state.setSelectedItem)
40
+ const setMode = useEditor((state) => state.setMode)
41
+ const setTool = useEditor((state) => state.setTool)
19
42
 
20
- const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
43
+ const sourceItems = itemsOverride ?? CATALOG_ITEMS
44
+ // Server-provided results bypass all local filtering; otherwise filter by category/search/tags
45
+ const filteredItems =
46
+ overrideItems ??
47
+ (() => {
48
+ const categoryItems = search
49
+ ? sourceItems
50
+ : sourceItems.filter((item) => item.category === category)
51
+ return categoryItems.filter((item) => {
52
+ const tags = item.tags ?? []
53
+ if (activePlacementTag && !tags.includes(activePlacementTag)) return false
54
+ if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
55
+ if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false
56
+ return true
57
+ })
58
+ })()
21
59
 
22
- // Auto-select first item if current selection is not in this category
60
+ const categoryItems = filteredItems
61
+
62
+ // Auto-select first item if current selection is not in the filtered list
23
63
  useEffect(() => {
24
64
  const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
25
65
  if (!isCurrentItemInCategory && categoryItems.length > 0) {
@@ -27,58 +67,60 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
27
67
  }
28
68
  }, [categoryItems, selectedItem?.src, setSelectedItem])
29
69
 
30
- // Get attachment icon based on attachTo type
31
70
  const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
32
- if (attachTo === 'wall' || attachTo === 'wall-side') {
33
- return '/icons/wall.png'
34
- }
35
- if (attachTo === 'ceiling') {
36
- return '/icons/ceiling.png'
37
- }
71
+ if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
72
+ if (attachTo === 'ceiling') return '/icons/ceiling.png'
38
73
  return null
39
74
  }
40
75
 
76
+ if (filteredItems.length === 0 && emptyState) {
77
+ return <>{emptyState}</>
78
+ }
79
+
41
80
  return (
42
- <div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
43
- {categoryItems.map((item, index) => {
81
+ <div
82
+ className="grid gap-2"
83
+ style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))' }}
84
+ >
85
+ {leadingTile}
86
+ {filteredItems.map((item, index) => {
44
87
  const isSelected = selectedItem?.src === item?.src
45
88
  const attachmentIcon = getAttachmentIcon(item?.attachTo)
46
89
  return (
47
- <Tooltip key={index}>
48
- <TooltipTrigger asChild>
49
- <button
50
- className={cn(
51
- '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',
52
- isSelected && 'ring-2 ring-primary-foreground',
53
- )}
54
- onClick={() => setSelectedItem(item)}
55
- type="button"
56
- >
57
- <Image
58
- alt={item.name}
59
- className="rounded-lg object-cover"
60
- fill
61
- loading="eager"
62
- sizes="56px"
63
- src={resolveCdnUrl(item.thumbnail) || ''}
64
- />
65
- {attachmentIcon && (
66
- <div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
67
- <Image
68
- alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
69
- className="h-4 w-4"
70
- height={16}
71
- src={attachmentIcon}
72
- width={16}
73
- />
74
- </div>
75
- )}
76
- </button>
77
- </TooltipTrigger>
78
- <TooltipContent className="text-xs" side="top">
90
+ <button
91
+ className={cn(
92
+ 'group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent',
93
+ isSelected && 'bg-sidebar-accent ring-2 ring-primary-foreground',
94
+ )}
95
+ key={index}
96
+ onClick={() => {
97
+ setSelectedItem(item)
98
+ setTool('item')
99
+ setMode('build')
100
+ }}
101
+ type="button"
102
+ >
103
+ <div className="relative aspect-square w-full overflow-hidden rounded-lg">
104
+ <img
105
+ alt={item.name}
106
+ className="h-full w-full object-cover"
107
+ loading="eager"
108
+ src={resolveCdnUrl(item.thumbnail) || ''}
109
+ />
110
+ {attachmentIcon && (
111
+ <div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
112
+ <img
113
+ alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
114
+ className="h-4 w-4"
115
+ src={attachmentIcon}
116
+ />
117
+ </div>
118
+ )}
119
+ </div>
120
+ <span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
79
121
  {item.name}
80
- </TooltipContent>
81
- </Tooltip>
122
+ </span>
123
+ </button>
82
124
  )
83
125
  })}
84
126
  </div>
@@ -2,8 +2,8 @@
2
2
 
3
3
  import type { LevelNode } from '@pascal-app/core'
4
4
  import { useEffect, useState } from 'react'
5
- import { cn } from '../../lib/utils'
6
5
  import type { LevelDuplicatePreset } from '../../lib/level-duplication'
6
+ import { cn } from '../../lib/utils'
7
7
  import {
8
8
  Dialog,
9
9
  DialogContent,
@@ -69,9 +69,7 @@ export function LevelDuplicateDialog({
69
69
  <DialogContent className="sm:max-w-md" showCloseButton={false}>
70
70
  <DialogHeader>
71
71
  <DialogTitle>Duplicate Level</DialogTitle>
72
- <DialogDescription>
73
- Choose what to copy from {getLevelLabel(level)}.
74
- </DialogDescription>
72
+ <DialogDescription>Choose what to copy from {getLevelLabel(level)}.</DialogDescription>
75
73
  </DialogHeader>
76
74
 
77
75
  <div className="grid gap-2">
@@ -95,7 +93,7 @@ export function LevelDuplicateDialog({
95
93
 
96
94
  <DialogFooter>
97
95
  <button
98
- className="cursor-pointer rounded-md px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent"
96
+ className="cursor-pointer rounded-md px-4 py-2 text-muted-foreground text-sm transition-colors hover:bg-accent"
99
97
  onClick={() => onOpenChange(false)}
100
98
  type="button"
101
99
  >
@@ -120,9 +120,8 @@ export function CeilingPanel() {
120
120
  const n = polygon.length
121
121
  for (let i = 0; i < n; i++) {
122
122
  const j = (i + 1) % n
123
- const current = polygon[i]
124
- const next = polygon[j]
125
- if (!(current && next)) continue
123
+ const current = polygon[i]!
124
+ const next = polygon[j]!
126
125
  area += current[0] * next[1]
127
126
  area -= next[0] * current[1]
128
127
  }
@@ -70,10 +70,12 @@ const COLUMN_PROPORTION_PRESETS = {
70
70
 
71
71
  type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
72
72
 
73
- const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({
74
- value: value as ColumnProportionPresetId,
75
- label: preset.label,
76
- }))
73
+ const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(
74
+ ([value, preset]) => ({
75
+ value: value as ColumnProportionPresetId,
76
+ label: preset.label,
77
+ }),
78
+ )
77
79
 
78
80
  function clamp(value: number, min: number, max: number) {
79
81
  return Math.min(max, Math.max(min, value))
@@ -201,7 +203,12 @@ export function ColumnPanel() {
201
203
  const shaftProfile = node.shaftProfile ?? 'straight'
202
204
 
203
205
  return (
204
- <PanelWrapper icon="/icons/column.png" onClose={handleClose} title={node.name || 'Column'} width={300}>
206
+ <PanelWrapper
207
+ icon="/icons/column.png"
208
+ onClose={handleClose}
209
+ title={node.name || 'Column'}
210
+ width={300}
211
+ >
205
212
  <PanelSection title="Preset">
206
213
  <select
207
214
  className={SELECT_CLASS}
@@ -223,7 +230,9 @@ export function ColumnPanel() {
223
230
  <PanelSection title="Shape">
224
231
  <select
225
232
  className={SELECT_CLASS}
226
- onChange={(event) => handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })}
233
+ onChange={(event) =>
234
+ handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
235
+ }
227
236
  value={node.crossSection}
228
237
  >
229
238
  <option value="round">Round</option>
@@ -313,7 +322,9 @@ export function ColumnPanel() {
313
322
  <PanelSection title="Shaft">
314
323
  <select
315
324
  className={SELECT_CLASS}
316
- onChange={(event) => handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))}
325
+ onChange={(event) =>
326
+ handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))
327
+ }
317
328
  value={shaftProfile}
318
329
  >
319
330
  <option value="straight">Straight</option>
@@ -487,10 +498,22 @@ export function ColumnPanel() {
487
498
  ? {}
488
499
  : {
489
500
  capitalHeight: Math.max(node.capitalHeight, 0.12),
490
- capitalTierCount: capitalStyle === 'stepped' ? Math.max(node.capitalTierCount ?? 3, 3) : node.capitalTierCount,
491
- capitalWidthScale: Math.max(node.capitalWidthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
492
- capitalDepthScale: Math.max(node.capitalDepthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
493
- capitalStepSpread: capitalStyle === 'stepped' ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) : node.capitalStepSpread,
501
+ capitalTierCount:
502
+ capitalStyle === 'stepped'
503
+ ? Math.max(node.capitalTierCount ?? 3, 3)
504
+ : node.capitalTierCount,
505
+ capitalWidthScale: Math.max(
506
+ node.capitalWidthScale ?? 1.3,
507
+ capitalStyle === 'stepped' ? 1.42 : 1.28,
508
+ ),
509
+ capitalDepthScale: Math.max(
510
+ node.capitalDepthScale ?? 1.3,
511
+ capitalStyle === 'stepped' ? 1.42 : 1.28,
512
+ ),
513
+ capitalStepSpread:
514
+ capitalStyle === 'stepped'
515
+ ? Math.max(node.capitalStepSpread ?? 0.34, 0.34)
516
+ : node.capitalStepSpread,
494
517
  }),
495
518
  })
496
519
  }}
@@ -572,13 +595,34 @@ export function ColumnPanel() {
572
595
  ? {}
573
596
  : {
574
597
  baseHeight: Math.max(node.baseHeight, 0.12),
575
- baseTierCount: baseStyle === 'stepped-square' ? Math.max(node.baseTierCount ?? 3, 3) : node.baseTierCount,
576
- baseWidthScale: Math.max(node.baseWidthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
577
- baseDepthScale: Math.max(node.baseDepthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
578
- baseStepSpread: baseStyle === 'stepped-square' ? Math.max(node.baseStepSpread ?? 0.34, 0.34) : node.baseStepSpread,
579
- basePlinthHeightRatio: baseStyle === 'round-rings' ? (node.basePlinthHeightRatio ?? 0.44) : node.basePlinthHeightRatio,
580
- baseRoundBandScale: baseStyle === 'round-rings' ? (node.baseRoundBandScale ?? 0.92) : node.baseRoundBandScale,
581
- baseNeckScale: baseStyle === 'round-rings' ? (node.baseNeckScale ?? 0.72) : node.baseNeckScale,
598
+ baseTierCount:
599
+ baseStyle === 'stepped-square'
600
+ ? Math.max(node.baseTierCount ?? 3, 3)
601
+ : node.baseTierCount,
602
+ baseWidthScale: Math.max(
603
+ node.baseWidthScale ?? 1.24,
604
+ baseStyle === 'stepped-square' ? 1.42 : 1.24,
605
+ ),
606
+ baseDepthScale: Math.max(
607
+ node.baseDepthScale ?? 1.24,
608
+ baseStyle === 'stepped-square' ? 1.42 : 1.24,
609
+ ),
610
+ baseStepSpread:
611
+ baseStyle === 'stepped-square'
612
+ ? Math.max(node.baseStepSpread ?? 0.34, 0.34)
613
+ : node.baseStepSpread,
614
+ basePlinthHeightRatio:
615
+ baseStyle === 'round-rings'
616
+ ? (node.basePlinthHeightRatio ?? 0.44)
617
+ : node.basePlinthHeightRatio,
618
+ baseRoundBandScale:
619
+ baseStyle === 'round-rings'
620
+ ? (node.baseRoundBandScale ?? 0.92)
621
+ : node.baseRoundBandScale,
622
+ baseNeckScale:
623
+ baseStyle === 'round-rings'
624
+ ? (node.baseNeckScale ?? 0.72)
625
+ : node.baseNeckScale,
582
626
  }),
583
627
  })
584
628
  }}