Repository: pascalorg/editor Branch: main Commit: 0bd0cab533a6 Files: 476 Total size: 23.0 MB Directory structure: gitextract_f568hpnc/ ├── .claude/ │ └── settings.json ├── .cursor/ │ └── rules/ │ ├── creating-rules.mdc │ ├── events.mdc │ ├── layers.mdc │ ├── node-schemas.mdc │ ├── renderers.mdc │ ├── scene-registry.mdc │ ├── selection-managers.mdc │ ├── spatial-queries.mdc │ ├── systems.mdc │ ├── tools.mdc │ └── viewer-isolation.mdc ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── pull_request_template.md │ └── workflows/ │ └── release.yml ├── .gitignore ├── .npmrc ├── .vscode/ │ └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SETUP.md ├── apps/ │ └── editor/ │ ├── .gitignore │ ├── README.md │ ├── app/ │ │ ├── api/ │ │ │ └── health/ │ │ │ └── route.ts │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── privacy/ │ │ │ └── page.tsx │ │ └── terms/ │ │ └── page.tsx │ ├── env.mjs │ ├── lib/ │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public/ │ │ ├── demos/ │ │ │ └── demo_1.json │ │ └── items/ │ │ ├── ac-block/ │ │ │ └── model.glb │ │ ├── air-conditioner/ │ │ │ └── model.glb │ │ ├── air-conditioner-block/ │ │ │ └── model.glb │ │ ├── air-conditioning/ │ │ │ └── model.glb │ │ ├── alarm-keypad/ │ │ │ └── model.glb │ │ ├── ball/ │ │ │ └── model.glb │ │ ├── barbell/ │ │ │ └── model.glb │ │ ├── barbell-stand/ │ │ │ └── model.glb │ │ ├── basket-hoop/ │ │ │ └── model.glb │ │ ├── bathroom-sink/ │ │ │ └── model.glb │ │ ├── bathtub/ │ │ │ └── model.glb │ │ ├── bean-bag/ │ │ │ └── model.glb │ │ ├── bedside-table/ │ │ │ └── model.glb │ │ ├── books/ │ │ │ └── model.glb │ │ ├── bookshelf/ │ │ │ └── model.glb │ │ ├── bunkbed/ │ │ │ └── model.glb │ │ ├── bush/ │ │ │ └── model.glb │ │ ├── cactus/ │ │ │ └── model.glb │ │ ├── car-toy/ │ │ │ └── model.glb │ │ ├── ceiling-fan/ │ │ │ └── model.glb │ │ ├── ceiling-lamp/ │ │ │ └── model.glb │ │ ├── ceiling-light/ │ │ │ └── model.glb │ │ ├── circular-ceiling-light/ │ │ │ └── model.glb │ │ ├── closet/ │ │ │ └── model.glb │ │ ├── coat-rack/ │ │ │ └── model.glb │ │ ├── coffee-machine/ │ │ │ └── model.glb │ │ ├── coffee-table/ │ │ │ └── model.glb │ │ ├── column/ │ │ │ └── model.glb │ │ ├── computer/ │ │ │ └── model.glb │ │ ├── couch-medium/ │ │ │ └── model.glb │ │ ├── couch-small/ │ │ │ └── model.glb │ │ ├── cutting-board/ │ │ │ └── model.glb │ │ ├── desk/ │ │ │ └── model.glb │ │ ├── dining-chair/ │ │ │ └── model.glb │ │ ├── dining-table/ │ │ │ └── model.glb │ │ ├── door/ │ │ │ └── model.glb │ │ ├── door-bar/ │ │ │ └── model.glb │ │ ├── door-with-bar/ │ │ │ └── model.glb │ │ ├── doorway-front/ │ │ │ └── model.glb │ │ ├── double-bed/ │ │ │ └── model.glb │ │ ├── dresser/ │ │ │ └── model.glb │ │ ├── drying-rack/ │ │ │ └── model.glb │ │ ├── easel/ │ │ │ └── model.glb │ │ ├── electric-panel/ │ │ │ └── model.glb │ │ ├── ev-wall-charger/ │ │ │ └── model.glb │ │ ├── exercise-bike/ │ │ │ └── model.glb │ │ ├── exit-sign/ │ │ │ └── model.glb │ │ ├── fence/ │ │ │ └── model.glb │ │ ├── fir-tree/ │ │ │ └── model.glb │ │ ├── fire-alarm/ │ │ │ └── model.glb │ │ ├── fire-detector/ │ │ │ └── model.glb │ │ ├── fire-extinguisher/ │ │ │ └── model.glb │ │ ├── flat-screen-tv/ │ │ │ └── model.glb │ │ ├── floor-lamp/ │ │ │ └── model.glb │ │ ├── freezer/ │ │ │ └── model.glb │ │ ├── fridge/ │ │ │ └── model.glb │ │ ├── fruits/ │ │ │ └── model.glb │ │ ├── frying-pan/ │ │ │ └── model.glb │ │ ├── glass-door/ │ │ │ └── model.glb │ │ ├── guitar/ │ │ │ └── model.glb │ │ ├── hedge/ │ │ │ └── model.glb │ │ ├── high-fence/ │ │ │ └── model.glb │ │ ├── hood/ │ │ │ └── model.glb │ │ ├── hydrant/ │ │ │ └── model.glb │ │ ├── indoor-plant/ │ │ │ └── model.glb │ │ ├── iron/ │ │ │ └── model.glb │ │ ├── ironing-board/ │ │ │ └── model.glb │ │ ├── kettle/ │ │ │ └── model.glb │ │ ├── kitchen/ │ │ │ └── model.glb │ │ ├── kitchen-cabinet/ │ │ │ └── model.glb │ │ ├── kitchen-counter/ │ │ │ └── model.glb │ │ ├── kitchen-fridge/ │ │ │ └── model.glb │ │ ├── kitchen-shelf/ │ │ │ └── model.glb │ │ ├── kitchen-utensils/ │ │ │ └── model.glb │ │ ├── laundry-bag/ │ │ │ └── model.glb │ │ ├── livingroom-chair/ │ │ │ └── model.glb │ │ ├── lounge-chair/ │ │ │ └── model.glb │ │ ├── low-fence/ │ │ │ └── model.glb │ │ ├── medium-fence/ │ │ │ └── model.glb │ │ ├── microwave/ │ │ │ └── model.glb │ │ ├── office-chair/ │ │ │ └── model.glb │ │ ├── office-table/ │ │ │ └── model.glb │ │ ├── outdoor-playhouse/ │ │ │ └── model.glb │ │ ├── palm/ │ │ │ └── model.glb │ │ ├── parking-spot/ │ │ │ └── model.glb │ │ ├── patio-umbrella/ │ │ │ └── model.glb │ │ ├── piano/ │ │ │ ├── Fireplace_13.glb │ │ │ └── model.glb │ │ ├── picture/ │ │ │ └── model.glb │ │ ├── pillar/ │ │ │ └── model.glb │ │ ├── pool-table/ │ │ │ └── model.glb │ │ ├── recessed-light/ │ │ │ └── model.glb │ │ ├── rectangular-carpet/ │ │ │ └── model.glb │ │ ├── rectangular-ceiling-light/ │ │ │ └── model.glb │ │ ├── rectangular-mirror/ │ │ │ └── model.glb │ │ ├── round-carpet/ │ │ │ └── model.glb │ │ ├── round-mirror/ │ │ │ └── model.glb │ │ ├── scooter/ │ │ │ └── model.glb │ │ ├── sewing-machine/ │ │ │ └── model.glb │ │ ├── shelf/ │ │ │ └── model.glb │ │ ├── shower/ │ │ │ └── model.glb │ │ ├── shower-angle/ │ │ │ └── model.glb │ │ ├── shower-rug/ │ │ │ └── model.glb │ │ ├── shower-square/ │ │ │ └── model.glb │ │ ├── single-bed/ │ │ │ └── model.glb │ │ ├── sink-cabinet/ │ │ │ └── model.glb │ │ ├── skate/ │ │ │ └── model.glb │ │ ├── small-indoor-plant/ │ │ │ └── model.glb │ │ ├── small-kitchen-cabinet/ │ │ │ └── model.glb │ │ ├── smoke-detector/ │ │ │ └── model.glb │ │ ├── sofa/ │ │ │ └── model.glb │ │ ├── sprinkler/ │ │ │ └── model.glb │ │ ├── stairs/ │ │ │ └── model.glb │ │ ├── stereo-speaker/ │ │ │ └── model.glb │ │ ├── stool/ │ │ │ └── model.glb │ │ ├── stove/ │ │ │ └── model.glb │ │ ├── sunbed/ │ │ │ └── model.glb │ │ ├── suspended-fireplace/ │ │ │ └── model.glb │ │ ├── table/ │ │ │ └── model.glb │ │ ├── table-lamp/ │ │ │ └── model.glb │ │ ├── television/ │ │ │ └── model.glb │ │ ├── tesla/ │ │ │ └── model.glb │ │ ├── thermostat/ │ │ │ └── model.glb │ │ ├── threadmill/ │ │ │ └── model.glb │ │ ├── toaster/ │ │ │ └── model.glb │ │ ├── toilet/ │ │ │ └── model.glb │ │ ├── toilet-brush/ │ │ │ └── model.glb │ │ ├── toilet-paper/ │ │ │ └── model.glb │ │ ├── toy/ │ │ │ └── model.glb │ │ ├── trash-bin/ │ │ │ └── model.glb │ │ ├── tree/ │ │ │ └── model.glb │ │ ├── tub/ │ │ │ └── model.glb │ │ ├── tv-stand/ │ │ │ └── model.glb │ │ ├── wall-art-06/ │ │ │ └── model.glb │ │ ├── wall-sink/ │ │ │ └── model.glb │ │ ├── washing-machine/ │ │ │ └── model.glb │ │ ├── window-double/ │ │ │ └── model.glb │ │ ├── window-large/ │ │ │ └── model.glb │ │ ├── window-rectangle/ │ │ │ └── model.glb │ │ ├── window-round/ │ │ │ └── model.glb │ │ ├── window-simple/ │ │ │ └── model.glb │ │ ├── window-small/ │ │ │ └── model.glb │ │ ├── window-small-2/ │ │ │ └── model.glb │ │ ├── window-square/ │ │ │ └── model.glb │ │ ├── window1-black-open-1731/ │ │ │ └── model.glb │ │ └── wine-bottle/ │ │ └── model.glb │ └── tsconfig.json ├── biome.jsonc ├── package.json ├── packages/ │ ├── core/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── events/ │ │ │ │ └── bus.ts │ │ │ ├── hooks/ │ │ │ │ ├── scene-registry/ │ │ │ │ │ └── scene-registry.ts │ │ │ │ └── spatial-grid/ │ │ │ │ ├── spatial-grid-manager.ts │ │ │ │ ├── spatial-grid-sync.ts │ │ │ │ ├── spatial-grid.ts │ │ │ │ ├── use-spatial-query.ts │ │ │ │ └── wall-spatial-grid.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── asset-storage.ts │ │ │ │ └── space-detection.ts │ │ │ ├── schema/ │ │ │ │ ├── base.ts │ │ │ │ ├── camera.ts │ │ │ │ ├── collections.ts │ │ │ │ ├── index.ts │ │ │ │ ├── material.ts │ │ │ │ ├── nodes/ │ │ │ │ │ ├── building.ts │ │ │ │ │ ├── ceiling.ts │ │ │ │ │ ├── door.ts │ │ │ │ │ ├── guide.ts │ │ │ │ │ ├── item.ts │ │ │ │ │ ├── level.ts │ │ │ │ │ ├── roof-segment.ts │ │ │ │ │ ├── roof.ts │ │ │ │ │ ├── scan.ts │ │ │ │ │ ├── site.ts │ │ │ │ │ ├── slab.ts │ │ │ │ │ ├── stair-segment.ts │ │ │ │ │ ├── stair.ts │ │ │ │ │ ├── wall.ts │ │ │ │ │ ├── window.ts │ │ │ │ │ └── zone.ts │ │ │ │ └── types.ts │ │ │ ├── store/ │ │ │ │ ├── actions/ │ │ │ │ │ └── node-actions.ts │ │ │ │ ├── use-interactive.ts │ │ │ │ └── use-scene.ts │ │ │ ├── systems/ │ │ │ │ ├── ceiling/ │ │ │ │ │ └── ceiling-system.tsx │ │ │ │ ├── door/ │ │ │ │ │ └── door-system.tsx │ │ │ │ ├── item/ │ │ │ │ │ └── item-system.tsx │ │ │ │ ├── roof/ │ │ │ │ │ └── roof-system.tsx │ │ │ │ ├── slab/ │ │ │ │ │ └── slab-system.tsx │ │ │ │ ├── stair/ │ │ │ │ │ └── stair-system.tsx │ │ │ │ ├── wall/ │ │ │ │ │ ├── wall-footprint.ts │ │ │ │ │ ├── wall-mitering.ts │ │ │ │ │ └── wall-system.tsx │ │ │ │ └── window/ │ │ │ │ └── window-system.tsx │ │ │ └── utils/ │ │ │ ├── clone-scene-graph.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── editor/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── editor/ │ │ │ │ │ ├── custom-camera-controls.tsx │ │ │ │ │ ├── editor-layout-v2.tsx │ │ │ │ │ ├── export-manager.tsx │ │ │ │ │ ├── first-person-controls.tsx │ │ │ │ │ ├── floating-action-menu.tsx │ │ │ │ │ ├── floorplan-panel.tsx │ │ │ │ │ ├── grid.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── node-action-menu.tsx │ │ │ │ │ ├── preset-thumbnail-generator.tsx │ │ │ │ │ ├── selection-manager.tsx │ │ │ │ │ ├── site-edge-labels.tsx │ │ │ │ │ ├── thumbnail-generator.tsx │ │ │ │ │ └── wall-measurement-label.tsx │ │ │ │ ├── feedback-dialog.tsx │ │ │ │ ├── pascal-radio.tsx │ │ │ │ ├── preview-button.tsx │ │ │ │ ├── systems/ │ │ │ │ │ ├── ceiling/ │ │ │ │ │ │ └── ceiling-system.tsx │ │ │ │ │ ├── roof/ │ │ │ │ │ │ └── roof-edit-system.tsx │ │ │ │ │ ├── stair/ │ │ │ │ │ │ └── stair-edit-system.tsx │ │ │ │ │ └── zone/ │ │ │ │ │ ├── zone-label-editor-system.tsx │ │ │ │ │ └── zone-system.tsx │ │ │ │ ├── tools/ │ │ │ │ │ ├── ceiling/ │ │ │ │ │ │ ├── ceiling-boundary-editor.tsx │ │ │ │ │ │ ├── ceiling-hole-editor.tsx │ │ │ │ │ │ └── ceiling-tool.tsx │ │ │ │ │ ├── door/ │ │ │ │ │ │ ├── door-math.ts │ │ │ │ │ │ ├── door-tool.tsx │ │ │ │ │ │ └── move-door-tool.tsx │ │ │ │ │ ├── item/ │ │ │ │ │ │ ├── item-tool.tsx │ │ │ │ │ │ ├── move-tool.tsx │ │ │ │ │ │ ├── placement-math.ts │ │ │ │ │ │ ├── placement-strategies.ts │ │ │ │ │ │ ├── placement-types.ts │ │ │ │ │ │ ├── use-draft-node.ts │ │ │ │ │ │ └── use-placement-coordinator.tsx │ │ │ │ │ ├── roof/ │ │ │ │ │ │ ├── move-roof-tool.tsx │ │ │ │ │ │ └── roof-tool.tsx │ │ │ │ │ ├── select/ │ │ │ │ │ │ └── box-select-tool.tsx │ │ │ │ │ ├── shared/ │ │ │ │ │ │ ├── cursor-sphere.tsx │ │ │ │ │ │ └── polygon-editor.tsx │ │ │ │ │ ├── site/ │ │ │ │ │ │ └── site-boundary-editor.tsx │ │ │ │ │ ├── slab/ │ │ │ │ │ │ ├── slab-boundary-editor.tsx │ │ │ │ │ │ ├── slab-hole-editor.tsx │ │ │ │ │ │ └── slab-tool.tsx │ │ │ │ │ ├── stair/ │ │ │ │ │ │ └── stair-tool.tsx │ │ │ │ │ ├── tool-manager.tsx │ │ │ │ │ ├── wall/ │ │ │ │ │ │ ├── wall-drafting.ts │ │ │ │ │ │ └── wall-tool.tsx │ │ │ │ │ ├── window/ │ │ │ │ │ │ ├── move-window-tool.tsx │ │ │ │ │ │ ├── window-math.ts │ │ │ │ │ │ └── window-tool.tsx │ │ │ │ │ └── zone/ │ │ │ │ │ ├── zone-boundary-editor.tsx │ │ │ │ │ └── zone-tool.tsx │ │ │ │ ├── ui/ │ │ │ │ │ ├── action-menu/ │ │ │ │ │ │ ├── action-button.tsx │ │ │ │ │ │ ├── camera-actions.tsx │ │ │ │ │ │ ├── control-modes.tsx │ │ │ │ │ │ ├── furnish-tools.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── structure-tools.tsx │ │ │ │ │ │ └── view-toggles.tsx │ │ │ │ │ ├── command-palette/ │ │ │ │ │ │ ├── editor-commands.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── controls/ │ │ │ │ │ │ ├── action-button.tsx │ │ │ │ │ │ ├── material-picker.tsx │ │ │ │ │ │ ├── metric-control.tsx │ │ │ │ │ │ ├── panel-section.tsx │ │ │ │ │ │ ├── segmented-control.tsx │ │ │ │ │ │ ├── slider-control.tsx │ │ │ │ │ │ └── toggle-control.tsx │ │ │ │ │ ├── floating-level-selector.tsx │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ ├── ceiling-helper.tsx │ │ │ │ │ │ ├── helper-manager.tsx │ │ │ │ │ │ ├── item-helper.tsx │ │ │ │ │ │ ├── roof-helper.tsx │ │ │ │ │ │ ├── slab-helper.tsx │ │ │ │ │ │ └── wall-helper.tsx │ │ │ │ │ ├── item-catalog/ │ │ │ │ │ │ ├── catalog-items.tsx │ │ │ │ │ │ └── item-catalog.tsx │ │ │ │ │ ├── panels/ │ │ │ │ │ │ ├── ceiling-panel.tsx │ │ │ │ │ │ ├── collections/ │ │ │ │ │ │ │ └── collections-popover.tsx │ │ │ │ │ │ ├── door-panel.tsx │ │ │ │ │ │ ├── item-panel.tsx │ │ │ │ │ │ ├── panel-manager.tsx │ │ │ │ │ │ ├── panel-wrapper.tsx │ │ │ │ │ │ ├── presets/ │ │ │ │ │ │ │ └── presets-popover.tsx │ │ │ │ │ │ ├── reference-panel.tsx │ │ │ │ │ │ ├── roof-panel.tsx │ │ │ │ │ │ ├── roof-segment-panel.tsx │ │ │ │ │ │ ├── slab-panel.tsx │ │ │ │ │ │ ├── stair-panel.tsx │ │ │ │ │ │ ├── stair-segment-panel.tsx │ │ │ │ │ │ ├── wall-panel.tsx │ │ │ │ │ │ └── window-panel.tsx │ │ │ │ │ ├── primitives/ │ │ │ │ │ │ ├── button.tsx │ │ │ │ │ │ ├── card.tsx │ │ │ │ │ │ ├── color-dot.tsx │ │ │ │ │ │ ├── context-menu.tsx │ │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ │ │ ├── error-boundary.tsx │ │ │ │ │ │ ├── input.tsx │ │ │ │ │ │ ├── number-input.tsx │ │ │ │ │ │ ├── opacity-control.tsx │ │ │ │ │ │ ├── popover.tsx │ │ │ │ │ │ ├── separator.tsx │ │ │ │ │ │ ├── sheet.tsx │ │ │ │ │ │ ├── shortcut-token.tsx │ │ │ │ │ │ ├── sidebar.tsx │ │ │ │ │ │ ├── skeleton.tsx │ │ │ │ │ │ ├── slider.tsx │ │ │ │ │ │ ├── switch.tsx │ │ │ │ │ │ └── tooltip.tsx │ │ │ │ │ ├── scene-loader.tsx │ │ │ │ │ ├── sidebar/ │ │ │ │ │ │ ├── app-sidebar.tsx │ │ │ │ │ │ ├── icon-rail.tsx │ │ │ │ │ │ ├── panels/ │ │ │ │ │ │ │ ├── settings-panel/ │ │ │ │ │ │ │ │ ├── audio-settings-dialog.tsx │ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ │ └── keyboard-shortcuts-dialog.tsx │ │ │ │ │ │ │ ├── site-panel/ │ │ │ │ │ │ │ │ ├── building-tree-node.tsx │ │ │ │ │ │ │ │ ├── ceiling-tree-node.tsx │ │ │ │ │ │ │ │ ├── door-tree-node.tsx │ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ │ ├── inline-rename-input.tsx │ │ │ │ │ │ │ │ ├── item-tree-node.tsx │ │ │ │ │ │ │ │ ├── level-tree-node.tsx │ │ │ │ │ │ │ │ ├── roof-tree-node.tsx │ │ │ │ │ │ │ │ ├── slab-tree-node.tsx │ │ │ │ │ │ │ │ ├── stair-tree-node.tsx │ │ │ │ │ │ │ │ ├── tree-node-actions.tsx │ │ │ │ │ │ │ │ ├── tree-node-drag.tsx │ │ │ │ │ │ │ │ ├── tree-node.tsx │ │ │ │ │ │ │ │ ├── wall-tree-node.tsx │ │ │ │ │ │ │ │ ├── window-tree-node.tsx │ │ │ │ │ │ │ │ └── zone-tree-node.tsx │ │ │ │ │ │ │ └── zone-panel/ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ └── tab-bar.tsx │ │ │ │ │ ├── slider-demo.tsx │ │ │ │ │ ├── slider.tsx │ │ │ │ │ └── viewer-toolbar.tsx │ │ │ │ ├── viewer-overlay.tsx │ │ │ │ └── viewer-zone-system.tsx │ │ │ ├── contexts/ │ │ │ │ └── presets-context.tsx │ │ │ ├── hooks/ │ │ │ │ ├── use-auto-save.ts │ │ │ │ ├── use-contextual-tools.ts │ │ │ │ ├── use-grid-events.ts │ │ │ │ ├── use-keyboard.ts │ │ │ │ ├── use-mobile.ts │ │ │ │ └── use-reduced-motion.ts │ │ │ ├── index.tsx │ │ │ ├── lib/ │ │ │ │ ├── constants.ts │ │ │ │ ├── level-selection.ts │ │ │ │ ├── scene.ts │ │ │ │ ├── sfx/ │ │ │ │ │ └── index.ts │ │ │ │ ├── sfx-bus.ts │ │ │ │ ├── sfx-player.ts │ │ │ │ └── utils.ts │ │ │ ├── store/ │ │ │ │ ├── use-audio.tsx │ │ │ │ ├── use-command-registry.ts │ │ │ │ ├── use-editor.tsx │ │ │ │ ├── use-palette-view-registry.ts │ │ │ │ └── use-upload.ts │ │ │ └── three-types.ts │ │ └── tsconfig.json │ ├── eslint-config/ │ │ ├── README.md │ │ ├── base.js │ │ ├── next.js │ │ ├── package.json │ │ └── react-internal.js │ ├── typescript-config/ │ │ ├── base.json │ │ ├── nextjs.json │ │ ├── package.json │ │ └── react-library.json │ ├── ui/ │ │ ├── eslint.config.mjs │ │ ├── package.json │ │ ├── src/ │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ └── code.tsx │ │ └── tsconfig.json │ └── viewer/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── components/ │ │ │ ├── error-boundary.tsx │ │ │ ├── renderers/ │ │ │ │ ├── building/ │ │ │ │ │ └── building-renderer.tsx │ │ │ │ ├── ceiling/ │ │ │ │ │ └── ceiling-renderer.tsx │ │ │ │ ├── door/ │ │ │ │ │ └── door-renderer.tsx │ │ │ │ ├── guide/ │ │ │ │ │ └── guide-renderer.tsx │ │ │ │ ├── item/ │ │ │ │ │ └── item-renderer.tsx │ │ │ │ ├── level/ │ │ │ │ │ └── level-renderer.tsx │ │ │ │ ├── node-renderer.tsx │ │ │ │ ├── roof/ │ │ │ │ │ ├── roof-materials.ts │ │ │ │ │ └── roof-renderer.tsx │ │ │ │ ├── roof-segment/ │ │ │ │ │ └── roof-segment-renderer.tsx │ │ │ │ ├── scan/ │ │ │ │ │ └── scan-renderer.tsx │ │ │ │ ├── scene-renderer.tsx │ │ │ │ ├── site/ │ │ │ │ │ └── site-renderer.tsx │ │ │ │ ├── slab/ │ │ │ │ │ └── slab-renderer.tsx │ │ │ │ ├── stair/ │ │ │ │ │ └── stair-renderer.tsx │ │ │ │ ├── stair-segment/ │ │ │ │ │ └── stair-segment-renderer.tsx │ │ │ │ ├── wall/ │ │ │ │ │ └── wall-renderer.tsx │ │ │ │ ├── window/ │ │ │ │ │ └── window-renderer.tsx │ │ │ │ └── zone/ │ │ │ │ └── zone-renderer.tsx │ │ │ └── viewer/ │ │ │ ├── ground-occluder.tsx │ │ │ ├── index.tsx │ │ │ ├── lights.tsx │ │ │ ├── perf-monitor.tsx │ │ │ ├── post-processing.tsx │ │ │ ├── selection-manager.tsx │ │ │ └── viewer-camera.tsx │ │ ├── hooks/ │ │ │ ├── use-asset-url.ts │ │ │ ├── use-gltf-ktx2.tsx │ │ │ └── use-node-events.ts │ │ ├── index.ts │ │ ├── lib/ │ │ │ ├── asset-url.ts │ │ │ ├── layers.ts │ │ │ └── materials.ts │ │ ├── r3f.d.ts │ │ ├── store/ │ │ │ ├── use-item-light-pool.ts │ │ │ ├── use-viewer.d.ts │ │ │ └── use-viewer.ts │ │ └── systems/ │ │ ├── export/ │ │ │ └── export-system.tsx │ │ ├── guide/ │ │ │ └── guide-system.tsx │ │ ├── interactive/ │ │ │ └── interactive-system.tsx │ │ ├── item-light/ │ │ │ └── item-light-system.tsx │ │ ├── level/ │ │ │ ├── level-system.d.ts │ │ │ ├── level-system.tsx │ │ │ └── level-utils.ts │ │ ├── scan/ │ │ │ └── scan-system.tsx │ │ ├── wall/ │ │ │ └── wall-cutout.tsx │ │ └── zone/ │ │ └── zone-system.tsx │ └── tsconfig.json ├── tooling/ │ ├── release/ │ │ ├── android-playstore-release.sh │ │ ├── ios-appstore-release.sh │ │ └── release.sh │ └── typescript/ │ ├── base.json │ ├── expo.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json └── turbo.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/settings.json ================================================ { "permissions": { "allow": [ "Bash(npx turbo:*)" ] } } ================================================ FILE: .cursor/rules/creating-rules.mdc ================================================ --- description: How to create and maintain project rules globs: .cursor/rules/** alwaysApply: false --- # Creating Rules Rules live in two places and are kept in sync via symlinks: - `.cursor/rules/.mdc` — source of truth (Cursor format) - `.claude/rules/.md` — symlink pointing to the cursor file ## Workflow **1. Write the rule in `.cursor/rules/`** ``` .cursor/rules/my-rule.mdc ``` **2. Create a symlink in `.claude/rules/`** ```bash ln -s ../../.cursor/rules/my-rule.mdc .claude/rules/my-rule.md ``` The `../../` prefix is required because the symlink lives two levels deep. **3. Verify** ```bash ls -la .claude/rules/my-rule.md # → .claude/rules/my-rule.md -> ../../.cursor/rules/my-rule.mdc ``` ## Rule File Format ```markdown --- description: One-line summary of what this rule covers globs: alwaysApply: false --- # Rule Title Short intro paragraph. ## Section Concrete guidance with examples. ``` - Set `alwaysApply: true` only for rules that apply to every file in the project. - Use `globs` to scope a rule to specific paths (e.g. `packages/viewer/**`). ## Good Practices - Keep rules under 500 lines. Split large rules into smaller focused files. - Include concrete examples or reference real files with `@filename`. - Add a rule when the same mistake has been made more than once — not preemptively. - Prefer showing a correct example over listing prohibitions. ## Existing Rules | Rule | Covers | |---|---| | `creating-rules` | This file — how to add rules | | `renderers` | Node renderer pattern in `packages/viewer` | | `systems` | Core and viewer systems architecture | | `tools` | Editor tools structure in `apps/editor` | | `viewer-isolation` | Keeping `@pascal-app/viewer` editor-agnostic | | `scene-registry` | Global node ID → Object3D map and `useRegistry` | | `selection-managers` | Two-layer selection (viewer + editor), events, outliner | | `events` | Typed event bus — emitting and listening to node and grid events | | `node-schemas` | Zod schema pattern for node types, createNode, updateNode | | `spatial-queries` | Placement validation (canPlaceOnFloor/Wall/Ceiling) for tools | | `layers` | Three.js layer constants, ownership, and rendering separation | ================================================ FILE: .cursor/rules/events.mdc ================================================ --- description: Typed event bus — emitting and listening to node and grid events globs: packages/core/src/events/**,packages/viewer/**,apps/editor/** alwaysApply: false --- # Events The event bus (`emitter`) is a global `mitt` instance typed with `EditorEvents`. It decouples renderers (which emit) from selection managers and tools (which listen). **Source**: @packages/core/src/events/bus.ts ## Event Key Format ``` : ``` Example keys: `wall:click`, `item:enter`, `door:double-click`, `grid:pointerdown` ### Node Types `wall` `item` `site` `building` `level` `zone` `slab` `ceiling` `roof` `window` `door` ### Suffixes ```ts 'click' | 'move' | 'enter' | 'leave' | 'pointerdown' | 'pointerup' | 'context-menu' | 'double-click' ``` The `grid:*` events fire when the user interacts with empty space (no node hit). They are **not** emitted by a mesh — `useGridEvents(gridY)` (@apps/editor/hooks/use-grid-events.ts) manually raycasts against a ground plane and calls `emitter.emit('grid:click', …)`. Mount it in any tool or editor component that needs empty-space interactions. ## NodeEvent Shape ```ts interface NodeEvent { node: T // typed node that triggered the event position: [number, number, number] // world-space hit position localPosition: [number, number, number] // object-local hit position normal?: [number, number, number] // face normal, if available stopPropagation: () => void nativeEvent: ThreeEvent } ``` Grid events only carry `position` and `nativeEvent` (no `node`). ## Emitting Renderers emit via `useNodeEvents` — never call `emitter.emit` directly in a renderer: ```tsx // packages/viewer/src/hooks/use-node-events.ts const events = useNodeEvents(node, 'wall') return ``` `useNodeEvents` converts R3F `ThreeEvent` into a `NodeEvent` and emits `wall:click`, `wall:enter`, etc. It suppresses events while the camera is dragging. ## Listening Listen in a `useEffect`. Always clean up with `emitter.off` using the **same function reference**: ```ts // Single event useEffect(() => { const handler = (e: WallEvent) => { /* … */ } emitter.on('wall:click', handler) return () => emitter.off('wall:click', handler) }, []) // Multiple node types, same handler useEffect(() => { const types = ['wall', 'slab', 'door'] as const const handler = (e: NodeEvent) => { /* … */ } types.forEach(t => emitter.on(`${t}:click`, handler as any)) return () => types.forEach(t => emitter.off(`${t}:click`, handler as any)) }, []) ``` See @apps/editor/components/editor/selection-manager.tsx for a full multi-type listener example. ## Rules - **Renderers only emit, never listen.** Listening belongs in selection managers, tools, or systems. - **Always clean up.** Forgetting `emitter.off` causes duplicate handlers and memory leaks. - **Use the same function reference** for `on` and `off`. Anonymous functions inside `useEffect` are fine as long as the ref is captured in the same scope. - **Don't use emitter for state.** It's for one-shot interaction events. Persistent state goes in `useScene`, `useViewer`, or `useEditor`. - **`stopPropagation`** prevents the event from being handled by overlapping listeners (e.g. a door on a wall). Call it when a handler should be the final consumer. ================================================ FILE: .cursor/rules/layers.mdc ================================================ --- description: Three.js layer conventions — which layer each object type lives on and why globs: packages/viewer/**,apps/editor/** alwaysApply: false --- # Three.js Layers Three.js `Layers` control which objects each camera and render pass sees. We use them to separate scene geometry, editor helpers, and zone overlays into distinct rendering buckets without duplicating scene structure. ## Layer Map | Constant | Value | Package | Purpose | |---|---|---|---| | `SCENE_LAYER` | `0` | `@pascal-app/viewer` | Default Three.js layer — all regular scene geometry | | `EDITOR_LAYER` | `1` | `apps/editor` | Editor-only helpers: grid, tool previews, cursor meshes, snap guides | | `ZONE_LAYER` | `2` | `@pascal-app/viewer` | Zone floor fills and wall borders — composited in a separate post-processing pass | Import the constants from their owning packages: ```ts // In viewer code import { SCENE_LAYER, ZONE_LAYER } from '@pascal-app/viewer' // In editor code import { EDITOR_LAYER } from '@/lib/constants' ``` ## Why Separate Zones onto Layer 2 Zones use semi-transparent, `depthTest: false` materials that must be composited *on top of* the scene without being fed into SSGI or TRAA. The post-processing pipeline in `post-processing.tsx` renders a dedicated `zonePass` with a `Layers` mask that enables only `ZONE_LAYER` (and disables `SCENE_LAYER`), then blends its output into the final composite manually: ```ts const zoneLayers = useMemo(() => { const l = new Layers() l.enable(ZONE_LAYER) l.disable(SCENE_LAYER) return l }, []) zonePass.setLayers(zoneLayers) ``` This keeps zones out of the SSGI depth/normal buffers (which would produce incorrect AO on transparent surfaces) while still letting them appear correctly over the scene. ## Why Separate Editor Helpers onto Layer 1 The editor camera enables `EDITOR_LAYER` so tools and helpers are visible during editing. The thumbnail generator disables `EDITOR_LAYER` so exports show clean geometry without snap lines or cursor spheres. ## Rules - **Never hardcode layer numbers.** Always use the named constants. - **`SCENE_LAYER` and `ZONE_LAYER` belong in `@pascal-app/viewer`** — they are renderer concerns, not editor concerns. - **`EDITOR_LAYER` belongs in `apps/editor`** — the viewer must never import it; editor behaviour is injected via props/children. - **Zone meshes must set `layers={ZONE_LAYER}`** so they are picked up by `zonePass` and excluded from `scenePass` depth buffers. - **Editor helper meshes must set `layers={EDITOR_LAYER}`** so they are invisible to the thumbnail camera and the viewer's render passes. - **Do not add new layers without updating this rule** and the post-processing pipeline accordingly. ================================================ FILE: .cursor/rules/node-schemas.mdc ================================================ --- description: Node type definitions, Zod schema pattern, and how to create nodes in the scene globs: packages/core/src/schema/** alwaysApply: false --- # Node Schemas All node types are defined as Zod schemas in `packages/core/src/schema/nodes/`. Each schema extends `BaseNode` and exports both the schema and its inferred TypeScript type. **Sources**: @packages/core/src/schema/base.ts, @packages/core/src/schema/nodes/ ## BaseNode Every node shares these fields: ```ts { object: 'node' // always literal 'node' id: string // typed ID e.g. "wall_abc123" type: string // node type discriminator e.g. "wall" name?: string // optional display name parentId: string | null // parent node ID; null = root visible: boolean // defaults to true metadata: Record // arbitrary JSON, defaults to {} } ``` ## Defining a New Node Type ```ts // packages/core/src/schema/nodes/my-node.ts import { z } from 'zod' import { BaseNode, objectId, nodeType } from '../base' export const MyNode = BaseNode.extend({ id: objectId('my-node'), // generates IDs like "my-node_abc123" type: nodeType('my-node'), // sets literal type discriminator // add node-specific fields: width: z.number().default(1), label: z.string().optional(), }).describe('My node — one-line description of what it represents') export type MyNode = z.infer export type MyNodeId = MyNode['id'] ``` Then add `MyNode` to the `AnyNode` union in `packages/core/src/schema/types.ts`. ## Creating Nodes in Tools Always use `.parse()` to validate and generate a proper typed ID. Never construct a plain object manually. ```ts import { WallNode } from '@pascal-app/core' import { useScene } from '@pascal-app/core' // 1. Parse validates and fills defaults (including auto-generated id) const wall = WallNode.parse({ name: 'Wall 1', start: [0, 0], end: [5, 0] }) // 2. createNode(node, parentId?) inserts it into the scene const { createNode } = useScene.getState() createNode(wall, levelId) ``` For batch creation: ```ts const { createNodes } = useScene.getState() createNodes([ { node: WallNode.parse({ start: [0, 0], end: [5, 0] }), parentId: levelId }, { node: WallNode.parse({ start: [5, 0], end: [5, 4] }), parentId: levelId }, ]) ``` ## Updating Nodes ```ts const { updateNode } = useScene.getState() updateNode(wall.id, { height: 2.8 }) // partial update, merges with existing ``` ## Real Examples - **Simple geometry node**: @packages/core/src/schema/nodes/wall.ts — `start`, `end`, `thickness`, `height` - **Polygon node**: @packages/core/src/schema/nodes/slab.ts — `polygon: [number, number][]`, `holes` - **Positioned node**: @packages/core/src/schema/nodes/item.ts — `position`, `rotation`, `scale`, `asset` ## Rules - **Always use `.parse()`** — it generates the correct ID prefix and fills defaults. `WallNode.parse({...})` not `{ type: 'wall', id: '...' }`. - **Never hardcode IDs.** Let `objectId('type')` generate them. - **Add new node types to `AnyNode`** in `types.ts` or they won't be accepted by the store. - **Keep schemas in `packages/core`**, not in the viewer or editor — the schema is shared by all packages. ================================================ FILE: .cursor/rules/renderers.mdc ================================================ --- description: Node renderer pattern in packages/viewer globs: packages/viewer/** alwaysApply: false --- # Renderers Renderers live in `packages/viewer/src/components/renderers/`. Each renderer is responsible for one node type's Three.js geometry and materials — nothing else. ## Dispatch Chain ``` — iterates rootNodeIds from useScene └─ — switches on node.type, renders the matching component └─ — (or SlabRenderer, DoorRenderer, …) ``` See @packages/viewer/src/components/renderers/scene-renderer.tsx and @packages/viewer/src/components/renderers/node-renderer.tsx. ## Renderer Responsibilities A renderer **should**: - Read its node from `useScene` via the node's ID - Register its mesh(es) with `useRegistry()` so other systems can look them up - Subscribe to pointer events via `useNodeEvents()` - Render geometry and apply materials based on node properties A renderer **must not**: - Run geometry generation logic (that belongs in a System) - Import anything from `apps/editor` - Manage selection state directly (use `useViewer` for read, emit events for write) - Perform expensive per-frame calculations in the component body ## Example — Minimal Renderer ```tsx // packages/viewer/src/components/renderers/my-node/index.tsx import { useRegistry } from '@pascal-app/core' import { useNodeEvents } from '../../hooks/use-node-events' import { useScene } from '@pascal-app/core' export function MyNodeRenderer({ node }: { node: MyNode }) { const ref = useRef(null!) useRegistry(node.id, 'my-node', ref) // 3 args: id, type, ref — no return value const events = useNodeEvents(node, 'my-node') return ( ) } ``` ## Adding a New Node Type 1. Create `packages/viewer/src/components/renderers//index.tsx` 2. Add a case to `NodeRenderer` in `node-renderer.tsx` 3. Add the corresponding system in `packages/core/src/systems/` if the node needs derived geometry 4. Export from `packages/viewer/src/index.ts` if needed externally ## Performance Notes - Use `useMemo` for geometry that depends on node properties — avoid recreating on every render. - For complex cutout or boolean geometry, delegate to a System (e.g. `WallCutout`). - Register one mesh per node ID; if a renderer spawns multiple meshes, use a group ref or pick the primary one for registry. ================================================ FILE: .cursor/rules/scene-registry.mdc ================================================ --- description: Scene registry pattern — mapping node IDs to live THREE.Object3D instances globs: packages/core/src/hooks/scene-registry/**,packages/viewer/** alwaysApply: false --- # Scene Registry The scene registry is a global, mutable map that links node IDs to their live `THREE.Object3D` instances. It avoids tree traversal and lets systems and selection managers do O(1) lookups. **Source**: @packages/core/src/hooks/scene-registry/scene-registry.ts ## Structure ```ts export const sceneRegistry = { nodes: new Map(), // id → Object3D byType: { wall: new Set(), slab: new Set(), item: new Set(), // … one Set per node type }, } ``` `nodes` is the primary lookup. `byType` lets systems iterate all objects of one type without scanning the whole map. ## Registering in a Renderer Every renderer must call `useRegistry` with a `ref` to its root mesh or group. Registration is synchronous (`useLayoutEffect`) so it's available before the first paint. ```tsx import { useRegistry } from '@pascal-app/core' export function WallRenderer({ node }: { node: WallNode }) { const ref = useRef(null!) useRegistry(node.id, 'wall', ref) // ← required in every renderer return } ``` The hook handles both registration on mount and cleanup on unmount automatically. ## Looking Up Objects Anywhere outside the renderer — in systems, selection managers, export logic: ```ts // Single lookup const obj = sceneRegistry.nodes.get(nodeId) if (obj) { /* use obj */ } // Iterate all walls for (const id of sceneRegistry.byType.wall) { const obj = sceneRegistry.nodes.get(id) } ``` ## Rules - **One registration per node ID.** If a renderer spawns multiple meshes, register the outermost group (the one that represents the node). - **Never hold a stale reference.** Always read from `sceneRegistry.nodes.get(id)` at the time you need it — don't cache the result across frames. - **Don't mutate the registry manually.** Only `useRegistry` should add/remove entries. Systems and selection managers are read-only consumers. - **Core systems must not use the registry.** They work with plain node data. Only viewer systems and selection managers may do Three.js object lookups. ## Outliner Sync The `outliner` in `useViewer` holds live `Object3D[]` arrays used by the post-processing outline pass. Selection managers sync them imperatively for performance (array mutation rather than new allocations): ```ts outliner.selectedObjects.length = 0 for (const id of selection.selectedIds) { const obj = sceneRegistry.nodes.get(id) if (obj) outliner.selectedObjects.push(obj) } ``` See @packages/viewer/src/components/viewer/selection-manager.tsx for the full sync pattern. ================================================ FILE: .cursor/rules/selection-managers.mdc ================================================ --- description: Selection managers — two-layer architecture for viewer and editor selection globs: packages/viewer/src/components/viewer/selection-manager.tsx,apps/editor/components/editor/selection-manager.tsx alwaysApply: false --- # Selection Managers There are two selection managers. They are separate components, not the same component configured differently. | Component | Location | Knows about | |---|---|---| | `SelectionManager` | `packages/viewer/src/components/viewer/selection-manager.tsx` | Viewer state only | | `SelectionManager` (editor) | `apps/editor/components/editor/selection-manager.tsx` | Phase, mode, tool state | The viewer's manager is the default. The editor mounts its own manager as a child of ``, overriding the default behaviour via the viewer-isolation pattern. --- ## How Selection Works **Event flow:** ``` useNodeEvents(node, type) on a renderer mesh → emitter.emit('wall:click', NodeEvent) → SelectionManager listens via emitter.on(…) → calls useViewer.setSelection(…) → outliner sync re-runs → Three.js outline updates ``` `useNodeEvents` returns R3F pointer handlers. Spread them onto the mesh: ```tsx const events = useNodeEvents(node, 'wall') return ``` Events are suppressed during camera drag (`useViewer.getState().cameraDragging`). --- ## Viewer Selection Manager Hierarchical path: **Building → Level → Zone → Elements** At each level, only the next tier is selectable. Clicking outside deselects. The path is stored in `useViewer`: ```ts type SelectionPath = { buildingId: string | null levelId: string | null zoneId: string | null selectedIds: string[] // walls, items, slabs, etc. } ``` `setSelection` has a hierarchy guard: setting `levelId` without `buildingId` resets children. Use `resetSelection()` to clear everything. Multi-select: `Ctrl/Meta + click` toggles an ID in `selectedIds`. Regular click replaces it. --- ## Editor Selection Manager Extends selection with phase awareness from `useEditor`. The viewer's `SelectionManager` is **not** mounted in the editor; this one takes its place (injected as a child of ``). ``` phase: 'site' → selectable: buildings phase: 'structure' → selectable: walls, zones, slabs, ceilings, roofs, doors, windows structureLayer: 'zones' → only zones structureLayer: 'elements' → all structure types phase: 'furnish' → selectable: furniture items only ``` Clicking a node of a different phase auto-switches the phase. Double-click drills into a context level. --- ## Rules - **Never add selection logic to renderers.** Renderers spread `useNodeEvents` events and stop there. All selection decisions live in the selection manager. - **Never add editor phase logic to the viewer's SelectionManager.** Phase, mode, and tool awareness belong exclusively in the editor's selection manager. - **`useViewer` is the single source of truth for selection state.** Both managers read and write through `setSelection` / `resetSelection`. Nothing else should mutate `selection` directly. - **Outliner arrays are mutated in-place** (not replaced) for performance. Don't assign new arrays to `outliner.selectedObjects` or `outliner.hoveredObjects`. - **Hover is a separate scalar** (`hoveredId: string | null`), not part of `selectedIds`. Update it via `setHoveredId`. --- ## Adding Selectability to a New Node Type 1. Add the type to `SelectableNodeType` in the viewer store / selection manager. 2. Make sure its renderer calls `useNodeEvents(node, type)` and spreads the handlers. 3. Add a case to whichever selection strategy needs it (viewer hierarchy level or editor phase). 4. Ensure `useRegistry` is called in the renderer so the outliner can highlight it. ================================================ FILE: .cursor/rules/spatial-queries.mdc ================================================ --- description: Placement validation for tools — canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling globs: apps/editor/components/tools/** alwaysApply: false --- # Spatial Queries `useSpatialQuery()` validates whether an item can be placed at a given position without overlapping existing items. Every placement tool must call it before committing a node to the scene. **Source**: @packages/core/src/hooks/spatial-grid/use-spatial-query.ts ## Hook ```ts const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery() ``` All three methods return `{ valid: boolean; conflictIds: string[] }`. `canPlaceOnWall` additionally returns `adjustedY: number` (snapped height). --- ## canPlaceOnFloor ```ts canPlaceOnFloor( levelId: string, position: [number, number, number], dimensions: [number, number, number], // scaled width/height/depth rotation: [number, number, number], ignoreIds?: string[], // pass [draftItem.id] to exclude self ): { valid: boolean; conflictIds: string[] } ``` **Usage in a tool:** ```ts const pos: [number, number, number] = [x, 0, z] const { valid } = canPlaceOnFloor(levelId, pos, getScaledDimensions(item), item.rotation, [item.id]) if (valid) createNode(item, levelId) ``` --- ## canPlaceOnWall ```ts canPlaceOnWall( levelId: string, wallId: string, localX: number, // distance along wall from start localY: number, // height from floor dimensions: [number, number, number], attachType: 'wall' | 'wall-side', // 'wall' needs clearance both sides; 'wall-side' only one side?: 'front' | 'back', ignoreIds?: string[], ): { valid: boolean; conflictIds: string[]; adjustedY: number } ``` `adjustedY` contains the snapped Y so items sit flush on the slab — always use it instead of the raw `localY`: ```ts const { valid, adjustedY } = canPlaceOnWall(levelId, wallId, x, y, dims, 'wall', undefined, [item.id]) if (valid) updateNode(item.id, { wallT: x, wallY: adjustedY }) ``` --- ## canPlaceOnCeiling ```ts canPlaceOnCeiling( ceilingId: string, position: [number, number, number], dimensions: [number, number, number], rotation: [number, number, number], ignoreIds?: string[], ): { valid: boolean; conflictIds: string[] } ``` --- ## Slab Elevation When items rest on a slab (not flat ground), use these to get the correct Y: ```ts import { spatialGridManager } from '@pascal-app/core' // Y at a single point const y = spatialGridManager.getSlabElevationAt(levelId, x, z) // Y considering the item's full footprint (highest slab point under item) const y = spatialGridManager.getSlabElevationForItem(levelId, position, dimensions, rotation) ``` --- ## Rules - **Always pass `[item.id]` in `ignoreIds`** when validating a draft item that already exists in the scene — otherwise it collides with itself. - **Use `adjustedY` from `canPlaceOnWall`** — don't use the raw cursor Y for wall-mounted items. - **Use `getScaledDimensions(item)`** (@packages/core/src/schema/nodes/item.ts) to account for item scale, not the raw `asset.dimensions`. - Validate on every pointer move for live feedback (highlight ghost red/green). Only `createNode` / `updateNode` on pointer up or click. See @apps/editor/components/tools/item/use-placement-coordinator.tsx for a full implementation. ================================================ FILE: .cursor/rules/systems.mdc ================================================ --- description: Core and viewer systems architecture globs: packages/core/src/systems/**,packages/viewer/src/systems/** alwaysApply: false --- # Systems Systems own business logic, geometry generation, and constraints. They run in the Three.js frame loop and are never rendered directly. ## Two Kinds of Systems ### Core Systems — `packages/core/src/systems/` Pure logic: no rendering, no Three.js objects. They read nodes from `useScene`, compute derived values (geometry, constraints), and write results back. | System | Responsibility | |---|---| | `WallSystem` | Wall mitering, corner joints | | `SlabSystem` | Polygon-based floor/roof generation | | `CeilingSystem` | Polygon-based ceiling generation | | `RoofSystem` | Pitched roof shape | | `DoorSystem` | Placement constraints on walls | | `WindowSystem` | Placement constraints on walls | | `ItemSystem` | Item transforms, collision | ### Viewer Systems — `packages/viewer/src/systems/` Access Three.js objects (via `useRegistry`) and manage rendering side-effects. | System | Responsibility | |---|---| | `LevelSystem` | Stacked / exploded / solo / manual level positions | | `WallCutout` | Cuts door/window holes in wall geometry | | `ZoneSystem` | Zone display and label placement | | `InteractiveSystem` | Item toggles and sliders in the scene | | `GuideSystem` | Temporary helper geometry | | `ScanSystem` | Point cloud rendering | ## Pattern Systems are React components that render nothing (`return null`) and use `useFrame` for per-frame logic. ```tsx // packages/core/src/systems/my-system.tsx import { useFrame } from '@react-three/fiber' import { useScene } from '../store/use-scene' export function MySystem() { const nodes = useScene(s => s.nodes) useFrame(() => { // compute and write back derived state }) return null } ``` Core and viewer systems are mounted inside `` alongside renderers. See @packages/viewer/src/components/viewer/index.tsx for the mount order. **Systems are a customization point.** Any consumer of `` — the editor app, an embed, a read-only preview — can inject its own systems as children. This is how editor-specific behaviour (space detection, tool feedback) is added without touching the viewer package. ## Rules - **Core systems must not import Three.js** — they work with plain data. - **Viewer systems must not contain business logic** — delegate to core if the rule is domain-level. - **Never duplicate logic** between a system and a renderer — if the renderer needs it, the system should compute and store it, and the renderer reads the result. - Systems should be **idempotent**: given the same nodes, they produce the same output. - Mark nodes as `dirty` in the scene store to signal that a system should re-run. Avoid running expensive logic every frame without a dirty check. ## Adding a New System 1. Decide the scope: - **Domain logic** → `packages/core/src/systems/` - **Viewer rendering side-effect** → `packages/viewer/src/systems/` — mount in `packages/viewer/src/components/viewer/index.tsx` - **Editor-specific or integration-specific** → keep it in the consuming app (e.g. `apps/editor/components/systems/`) and inject it as a child of `` 2. Create `-system.tsx` in the appropriate directory. 3. Mount it in the right place: - Viewer-internal systems go in `packages/viewer/src/components/viewer/index.tsx` - App-specific systems are injected as children from outside: ```tsx // apps/editor — editor injects its own systems without modifying the viewer ``` 4. **Mount order matters.** Most viewer systems run *after* renderers in the JSX tree — they consume `sceneRegistry` data that renderers populate on mount. Only place a system before renderers if it explicitly does not read the registry. ================================================ FILE: .cursor/rules/tools.mdc ================================================ --- description: Editor tools structure in apps/editor globs: apps/editor/components/tools/** alwaysApply: false --- # Tools Tools are React components that capture user input (pointer, keyboard) and translate it into `useScene` mutations. They live exclusively in `apps/editor/components/tools/`. ## Lifecycle `ToolManager` reads `useEditor` (phase + mode + tool) and mounts the active tool component. When the tool changes, the old component unmounts, cleaning up any transient state. See @apps/editor/components/tools/tool-manager.tsx. ## Tool Categories by Phase **Site** - `site-boundary-editor` — draw/edit property boundary polygon **Structure** - `wall-tool` — draw walls segment by segment - `slab-tool` + `slab-boundary-editor` + `slab-hole-editor` - `ceiling-tool` + `ceiling-boundary-editor` + `ceiling-hole-editor` - `roof-tool` - `door-tool` + `door-move-tool` - `window-tool` + `window-move-tool` - `item-tool` + `item-move-tool` - `zone-tool` + `zone-boundary-editor` **Furnish** - `item-tool` — place furniture **Shared utilities** - `polygon-editor` — reusable boundary/hole editing logic - `cursor-sphere` — 3D cursor visualisation ## Pattern ```tsx // apps/editor/components/tools/my-tool/index.tsx import { useScene } from '@pascal-app/core' import { useEditor } from '../../store/use-editor' export function MyTool() { const createNode = useScene(s => s.createNode) const setTool = useEditor(s => s.setTool) // Pointer handlers mutate the scene store directly. // No local geometry — use a renderer for any preview mesh. return ( {/* ghost / preview geometry only */} ) } ``` ## Rules - **Tools only mutate `useScene`** — they do not call Three.js APIs directly. - **No business logic in tools** — delegate geometry/constraint rules to core systems. - **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store. - **Clean up on unmount** — remove any pending/incomplete nodes when the tool unmounts. - **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. - Each tool should handle a single, well-scoped interaction. Split complex tools (e.g. "draw + move") into separate components selected by `useEditor`. ## Adding a New Tool 1. Create `apps/editor/components/tools//index.tsx`. 2. Register the tool in `ToolManager` under the correct phase and mode. 3. Add the tool identifier to the `useEditor` tool union type. 4. If the tool requires new node types, add schema + renderer + system first. ================================================ FILE: .cursor/rules/viewer-isolation.mdc ================================================ --- description: Viewer must be editor-agnostic — controlled from outside via props and children globs: packages/viewer/** alwaysApply: false --- # Viewer Isolation `@pascal-app/viewer` is a standalone 3D canvas library. It must never know about editor-specific features, UI state, or tools. This keeps it usable in the read-only `/viewer/[id]` route and in any future embedding context. ## The Rule > The viewer is controlled from outside. It exposes control points (props, callbacks, children). It never reaches into `apps/editor`. ## Forbidden in `packages/viewer` ```ts // ❌ Never import from the editor app import { useEditor } from '@/store/use-editor' import { ToolManager } from '@/components/tools/tool-manager' // ❌ Never reference editor-specific concepts if (isEditorMode) { … } ``` ## Correct Pattern — Pass Control from Outside The editor mounts the viewer and passes what it needs: ```tsx // apps/editor/components/editor-canvas.tsx ✅ import { Viewer } from '@pascal-app/viewer' import { ToolManager } from '../tools/tool-manager' import { useEditor } from '../../store/use-editor' export function EditorCanvas() { const { selection } = useViewer() return ( useViewer.getState().setSelection(id)} onExport={handleExport} > {/* Editor injects tools as children — viewer renders them inside the canvas */} ) } ``` The viewer accepts `children` and renders them inside the R3F canvas. This is the extension point for tools, overlays, and editor-specific systems. ## Viewer's Own State (`useViewer`) The viewer store contains **only presentation state**: - `selection` — which nodes are highlighted - `cameraMode` — perspective / orthographic - `levelMode` — stacked / exploded / solo / manual - `wallMode` — up / cutaway / down - `theme` — light / dark - Display toggles: `showScans`, `showGuides`, `showGrid` If a piece of state is only meaningful inside the editor (e.g. active tool, phase, edit mode) — it belongs in `useEditor`, not `useViewer`. ## Nested Viewer for Editor-Specific Features When an editor feature needs to live "inside" the canvas but must not pollute the viewer package, inject it as a child: ```tsx // ✅ Editor-specific overlay injected as child {/* editor only */} {/* editor only */} {/* editor only */} ``` This pattern lets the viewer stay ignorant of these components while they still have access to the R3F context. ## Checklist Before Adding Code to `packages/viewer` - [ ] Does this feature make sense in the read-only viewer route? - [ ] Does it reference `useEditor`, tool state, or phase/mode? - [ ] Could it be passed in as a prop or child instead? If any answer is "editor-specific", keep it in `apps/editor` and inject it via children or props. ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐛 Bug Report description: Report something that isn't working correctly labels: ["bug", "needs-triage"] body: - type: markdown attributes: value: | Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix the issue. - type: textarea id: description attributes: label: What happened? description: A clear description of the bug. placeholder: Describe what went wrong... validations: required: true - type: textarea id: steps attributes: label: Steps to reproduce description: How can we reproduce the issue? placeholder: | 1. Go to '...' 2. Click on '...' 3. See error validations: required: true - type: textarea id: expected attributes: label: Expected behavior description: What did you expect to happen? validations: required: true - type: input id: browser attributes: label: Browser & OS description: e.g. Chrome 120, macOS 14 placeholder: Chrome 120, macOS 14 validations: required: false - type: textarea id: screenshots attributes: label: Screenshots or screen recordings description: | A short screen recording is the fastest way to show us the bug. Drag and drop a video file or paste a link — even a quick clip helps a lot. validations: required: false - type: textarea id: additional attributes: label: Additional context description: Any other information that might be helpful (console errors, related issues, etc.) validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 💬 Questions & Help url: https://github.com/pascalorg/editor/discussions/categories/q-a about: Ask questions and get help from the community - name: 💡 Ideas & Discussion url: https://github.com/pascalorg/editor/discussions/categories/ideas about: Share ideas and discuss features before opening a formal request ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: ✨ Feature Request description: Suggest a new feature or improvement labels: ["enhancement", "needs-triage"] body: - type: markdown attributes: value: | We love hearing ideas from the community! Please describe what you'd like to see in Pascal Editor. - type: textarea id: problem attributes: label: Problem or motivation description: What problem does this solve? Why do you want this? placeholder: I'm always frustrated when... validations: required: true - type: textarea id: solution attributes: label: Proposed solution description: How do you think this should work? validations: required: false - type: textarea id: alternatives attributes: label: Alternatives considered description: Any workarounds or alternative approaches you've thought about? validations: required: false - type: textarea id: additional attributes: label: Additional context description: Mockups, screenshots, references to other tools, etc. validations: required: false ================================================ FILE: .github/pull_request_template.md ================================================ ## What does this PR do? ## How to test 1. 2. 3. ## Screenshots / screen recording ## Checklist - [ ] I've tested this locally with `bun dev` - [ ] My code follows the existing code style (run `bun check` to verify) - [ ] I've updated relevant documentation (if applicable) - [ ] This PR targets the `main` branch ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: workflow_dispatch: inputs: package: description: "Package to release" required: true type: choice options: - core - viewer - both bump: description: "Version bump" required: true type: choice options: - patch - minor - major dry-run: description: "Dry run (no publish)" required: false type: boolean default: false jobs: release: runs-on: ubuntu-latest environment: npm permissions: contents: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: oven-sh/setup-bun@v2 - uses: actions/setup-node@v4 with: node-version: 22 registry-url: "https://registry.npmjs.org" - name: Install dependencies run: bun install --frozen-lockfile - name: Configure git run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - name: Bump & publish core if: inputs.package == 'core' || inputs.package == 'both' 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" npm publish --dry-run --access public else npm publish --access public echo "📦 Published @pascal-app/core@$VERSION" fi - name: Bump & publish viewer if: inputs.package == 'viewer' || inputs.package == 'both' 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" npm publish --dry-run --access public else npm publish --access public echo "📦 Published @pascal-app/viewer@$VERSION" fi - name: Commit version bumps & tag if: inputs.dry-run == false run: | git add -A PKGS="" TAGS="" if [ -n "$CORE_VERSION" ]; then PKGS="$PKGS @pascal-app/core@$CORE_VERSION" TAGS="$TAGS @pascal-app/core@$CORE_VERSION" fi if [ -n "$VIEWER_VERSION" ]; then PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION" TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION" fi git commit -m "release:${PKGS}" for TAG in $TAGS; do git tag "$TAG" done git push --follow-tags ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Dependencies node_modules package-lock.json .pnp .pnp.js # Local env files .env .env.local .env.development.local .env.test.local .env.production.local # Testing coverage # Turbo .turbo # Supabase local state supabase/.branches/ supabase/.temp/ # Vercel .vercel # Build Outputs .next/ out/ build dist *.tsbuildinfo # Debug npm-debug.log* yarn-debug.log* yarn-error.log* # Misc .DS_Store *.pem /.playwright-mcp og-test .env*.local ================================================ FILE: .npmrc ================================================ ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsdk": "node_modules/typescript/lib", "biome.configPath": "./biome.jsonc", "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": false, "editor.formatOnPaste": true, "emmet.showExpandedAbbreviation": "never", "editor.codeActionsOnSave": { "source.fixAll.biome": "always", "quickfix.biome": "always", "source.organizeImports.biome": "always", "source.organizeImports": "never" }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, "[json]": { "editor.defaultFormatter": "biomejs.biome" }, "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, "[jsonc]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome", "editor.formatOnPaste": false }, "[javascriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "[css]": { "editor.defaultFormatter": "biomejs.biome" }, "css.lint.unknownAtRules": "ignore", "[graphql]": { "editor.defaultFormatter": "biomejs.biome" }, "evenBetterToml.formatter.allowedBlankLines": 4 } ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Pascal Editor Thanks for your interest in contributing! We welcome all kinds of contributions — bug fixes, new features, documentation, and ideas. ## Getting started ### Prerequisites - [Bun](https://bun.sh/) 1.3+ (or Node.js 18+) ### Setup ```bash git clone https://github.com/pascalorg/editor.git cd editor bun install bun dev ``` The editor will be running at **http://localhost:3000**. That's it! ### Optional Copy `.env.example` to `.env` and add a Google Maps API key if you want address search functionality. The editor works fully without it. ## Making changes ### Code style We use [Biome](https://biomejs.dev/) for linting and formatting. Before submitting a PR: ```bash bun check # Check for issues bun check:fix # Auto-fix issues ``` ### Project structure | Package | What it does | |---------|-------------| | `packages/core` | Scene schema, state management, systems — no UI | | `packages/viewer` | 3D rendering with React Three Fiber | | `apps/editor` | The full editor app (Next.js) | A key rule: **`packages/viewer` must never import from `apps/editor`**. The viewer is a standalone component; editor-specific behavior is injected via props/children. ## Submitting a PR 1. **Fork the repo** and create a branch from `main` 2. **Make your changes** and test locally with `bun dev` 3. **Run `bun check`** to make sure linting passes 4. **Open a PR** with a clear description of what changed and why 5. **Link related issues** if applicable (e.g., "Fixes #42") ### PR tips - Keep PRs focused — one feature or fix per PR - Include screenshots or recordings for visual changes - If you're unsure about an approach, open an issue or discussion first ## Reporting bugs Use the [Bug Report](https://github.com/pascalorg/editor/issues/new?template=bug_report.yml) template. Include steps to reproduce — this helps us fix things faster. ## Suggesting features Use the [Feature Request](https://github.com/pascalorg/editor/issues/new?template=feature_request.yml) template, or start a [Discussion](https://github.com/pascalorg/editor/discussions) if you want to brainstorm first. ## Questions? Head to [Discussions](https://github.com/pascalorg/editor/discussions) — we're happy to help! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2026 Pascal Group Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Pascal Editor A 3D building editor built with React Three Fiber and WebGPU. [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![npm @pascal-app/core](https://img.shields.io/npm/v/@pascal-app/core?label=%40pascal-app%2Fcore)](https://www.npmjs.com/package/@pascal-app/core) [![npm @pascal-app/viewer](https://img.shields.io/npm/v/@pascal-app/viewer?label=%40pascal-app%2Fviewer)](https://www.npmjs.com/package/@pascal-app/viewer) [![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/SaBRA9t2) [![X (Twitter)](https://img.shields.io/badge/follow-%40pascal__app-black?logo=x&logoColor=white)](https://x.com/pascal_app) https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b ## Repository Architecture This is a Turborepo monorepo with three main packages: ``` editor-v2/ ├── apps/ │ └── editor/ # Next.js application ├── packages/ │ ├── core/ # Schema definitions, state management, systems │ └── viewer/ # 3D rendering components ``` ### Separation of Concerns | Package | Responsibility | |---------|---------------| | **@pascal-app/core** | Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus | | **@pascal-app/viewer** | 3D rendering via React Three Fiber, default camera/controls, post-processing | | **apps/editor** | UI components, tools, custom behaviors, editor-specific systems | The **viewer** renders the scene with sensible defaults. The **editor** extends it with interactive tools, selection management, and editing capabilities. ### Stores Each package has its own Zustand store for managing state: | Store | Package | Responsibility | |-------|---------|----------------| | `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. | | `useViewer` | `@pascal-app/viewer` | Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. | | `useEditor` | `apps/editor` | Editor state: active tool, structure layer visibility, panel states, editor-specific preferences. | **Access patterns:** ```typescript // Subscribe to state changes (React component) const nodes = useScene((state) => state.nodes) const levelId = useViewer((state) => state.selection.levelId) const activeTool = useEditor((state) => state.tool) // Access state outside React (callbacks, systems) const node = useScene.getState().nodes[id] useViewer.getState().setSelection({ levelId: 'level_123' }) ``` --- ## Core Concepts ### Nodes Nodes are the data primitives that describe the 3D scene. All nodes extend `BaseNode`: ```typescript BaseNode { id: string // Auto-generated with type prefix (e.g., "wall_abc123") type: string // Discriminator for type-safe handling parentId: string | null // Parent node reference visible: boolean camera?: Camera // Optional saved camera position metadata?: JSON // Arbitrary metadata (e.g., { isTransient: true }) } ``` **Node Hierarchy:** ``` Site └── Building └── Level ├── Wall → Item (doors, windows) ├── Slab ├── Ceiling → Item (lights) ├── Roof ├── Zone ├── Scan (3D reference) └── Guide (2D reference) ``` Nodes are stored in a **flat dictionary** (`Record`), not a nested tree. Parent-child relationships are defined via `parentId` and `children` arrays. --- ### Scene State (Zustand Store) The scene is managed by a Zustand store in `@pascal-app/core`: ```typescript useScene.getState() = { nodes: Record, // All nodes rootNodeIds: string[], // Top-level nodes (sites) dirtyNodes: Set, // Nodes pending system updates createNode(node, parentId), updateNode(id, updates), deleteNode(id), } ``` **Middleware:** - **Persist** - Saves to IndexedDB (excludes transient nodes) - **Temporal** (Zundo) - Undo/redo with 50-step history --- ### Scene Registry The registry maps node IDs to their Three.js objects for fast lookup: ```typescript sceneRegistry = { nodes: Map, // ID → 3D object byType: { wall: Set, item: Set, zone: Set, // ... } } ``` Renderers register their refs using the `useRegistry` hook: ```tsx const ref = useRef(null!) useRegistry(node.id, 'wall', ref) ``` This allows systems to access 3D objects directly without traversing the scene graph. --- ### Node Renderers Renderers are React components that create Three.js objects for each node type: ``` SceneRenderer └── NodeRenderer (dispatches by type) ├── BuildingRenderer ├── LevelRenderer ├── WallRenderer ├── SlabRenderer ├── ZoneRenderer ├── ItemRenderer └── ... ``` **Pattern:** 1. Renderer creates a placeholder mesh/group 2. Registers it with `useRegistry` 3. Systems update geometry based on node data Example (simplified): ```tsx const WallRenderer = ({ node }) => { const ref = useRef(null!) useRegistry(node.id, 'wall', ref) return ( {/* Replaced by WallSystem */} {node.children.map(id => )} ) } ``` --- ### Systems Systems are React components that run in the render loop (`useFrame`) to update geometry and transforms. They process **dirty nodes** marked by the store. **Core Systems (in `@pascal-app/core`):** | System | Responsibility | |--------|---------------| | `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows | | `SlabSystem` | Generates floor geometry from polygons | | `CeilingSystem` | Generates ceiling geometry | | `RoofSystem` | Generates roof geometry | | `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) | **Viewer Systems (in `@pascal-app/viewer`):** | System | Responsibility | |--------|---------------| | `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo modes) | | `ScanSystem` | Controls 3D scan visibility | | `GuideSystem` | Controls guide image visibility | **Processing Pattern:** ```typescript useFrame(() => { for (const id of dirtyNodes) { const obj = sceneRegistry.nodes.get(id) const node = useScene.getState().nodes[id] // Update geometry, transforms, etc. updateGeometry(obj, node) dirtyNodes.delete(id) } }) ``` --- ### Dirty Nodes When a node changes, it's marked as **dirty** in `useScene.getState().dirtyNodes`. Systems check this set each frame and only recompute geometry for dirty nodes. ```typescript // Automatic: createNode, updateNode, deleteNode mark nodes dirty useScene.getState().updateNode(wallId, { thickness: 0.2 }) // → wallId added to dirtyNodes // → WallSystem regenerates geometry next frame // → wallId removed from dirtyNodes ``` **Manual marking:** ```typescript useScene.getState().dirtyNodes.add(wallId) ``` --- ### Event Bus Inter-component communication uses a typed event emitter (mitt): ```typescript // Node events emitter.on('wall:click', (event) => { ... }) emitter.on('item:enter', (event) => { ... }) emitter.on('zone:context-menu', (event) => { ... }) // Grid events (background) emitter.on('grid:click', (event) => { ... }) // Event payload NodeEvent { node: AnyNode position: [x, y, z] localPosition: [x, y, z] normal?: [x, y, z] stopPropagation: () => void } ``` --- ### Spatial Grid Manager Handles collision detection and placement validation: ```typescript spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation) spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions) spatialGridManager.getSlabElevationAt(levelId, x, z) ``` Used by item placement tools to validate positions and calculate slab elevations. --- ## Editor Architecture The editor extends the viewer with: ### Tools Tools are activated via the toolbar and handle user input for specific operations: - **SelectTool** - Selection and manipulation - **WallTool** - Draw walls - **ZoneTool** - Create zones - **ItemTool** - Place furniture/fixtures - **SlabTool** - Create floor slabs ### Selection Manager The editor uses a custom selection manager with hierarchical navigation: ``` Site → Building → Level → Zone → Items ``` Each depth level has its own selection strategy for hover/click behavior. ### Editor-Specific Systems - `ZoneSystem` - Controls zone visibility based on level mode - Custom camera controls with node focusing --- ## Data Flow ``` User Action (click, drag) ↓ Tool Handler ↓ useScene.createNode() / updateNode() ↓ Node added/updated in store Node marked dirty ↓ React re-renders NodeRenderer useRegistry() registers 3D object ↓ System detects dirty node (useFrame) Updates geometry via sceneRegistry Clears dirty flag ``` --- ## Technology Stack - **React 19** + **Next.js 16** - **Three.js** (WebGPU renderer) - **React Three Fiber** + **Drei** - **Zustand** (state management) - **Zod** (schema validation) - **Zundo** (undo/redo) - **three-bvh-csg** (Boolean geometry operations) - **Turborepo** (monorepo management) - **Bun** (package manager) --- ## Getting Started ### Development Run the development server from the **root directory** to enable hot reload for all packages: ```bash # Install dependencies bun install # Run development server (builds packages + starts editor with watch mode) bun dev # This will: # 1. Build @pascal-app/core and @pascal-app/viewer # 2. Start watching both packages for changes # 3. Start the Next.js editor dev server # Open http://localhost:3000 ``` **Important:** Always run `bun dev` from the root directory to ensure the package watchers are running. This enables hot reload when you edit files in `packages/core/src/` or `packages/viewer/src/`. ### Building for Production ```bash # Build all packages turbo build # Build specific package turbo build --filter=@pascal-app/core ``` ### Publishing Packages ```bash # Build packages turbo build --filter=@pascal-app/core --filter=@pascal-app/viewer # Publish to npm npm publish --workspace=@pascal-app/core --access public npm publish --workspace=@pascal-app/viewer --access public ``` --- ## Key Files | Path | Description | |------|-------------| | `packages/core/src/schema/` | Node type definitions (Zod schemas) | | `packages/core/src/store/use-scene.ts` | Scene state store | | `packages/core/src/hooks/scene-registry/` | 3D object registry | | `packages/core/src/systems/` | Geometry generation systems | | `packages/viewer/src/components/renderers/` | Node renderers | | `packages/viewer/src/components/viewer/` | Main Viewer component | | `apps/editor/components/tools/` | Editor tools | | `apps/editor/store/` | Editor-specific state | --- ## Contributors Aymeric Rabot Wassim Samad --- pascalorg/editor | Trendshift ================================================ FILE: SETUP.md ================================================ # Pascal Editor — Setup ## Prerequisites - [Bun](https://bun.sh/) 1.3+ (or Node.js 18+) ## Quick Start ```bash bun install bun dev ``` The editor will be running at **http://localhost:3000**. ## Environment Variables (optional) Copy `.env.example` to `.env` if you need: ```bash cp .env.example .env ``` | Variable | Required | Description | |----------|----------|-------------| | `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` | No | Enables address search in the editor | | `PORT` | No | Dev server port (default: 3000) | The editor works fully without any environment variables. ## Monorepo Structure ``` ├── apps/ │ └── editor/ # Next.js editor application ├── packages/ │ ├── core/ # @pascal-app/core — Scene schema, state, systems │ ├── viewer/ # @pascal-app/viewer — 3D rendering │ └── ui/ # Shared UI components └── tooling/ # Build & release tooling ``` ## Scripts | Command | Description | |---------|-------------| | `bun dev` | Start the development server | | `bun build` | Build all packages | | `bun check` | Lint and format check (Biome) | | `bun check:fix` | Auto-fix lint and format issues | | `bun check-types` | TypeScript type checking | ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines on submitting PRs and reporting issues. ================================================ FILE: apps/editor/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js .yarn/install-state.gz # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # env files (can opt-in for commiting if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts .env*.local ================================================ FILE: apps/editor/README.md ================================================ # Pascal Editor A 3D building editor built with React Three Fiber and WebGPU. ## Repository Architecture This is a Turborepo monorepo with three main packages: ``` editor-v2/ ├── apps/ │ └── editor/ # Next.js application (this package) ├── packages/ │ ├── core/ # Schema definitions, state management, systems │ └── viewer/ # 3D rendering components ``` ### Separation of Concerns | Package | Responsibility | |---------|---------------| | **@pascal-app/core** | Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus | | **@pascal-app/viewer** | 3D rendering via React Three Fiber, default camera/controls, post-processing | | **apps/editor** | UI components, tools, custom behaviors, editor-specific systems | The **viewer** renders the scene with sensible defaults. The **editor** extends it with interactive tools, selection management, and editing capabilities. ### Stores Each package has its own Zustand store for managing state: | Store | Package | Responsibility | |-------|---------|----------------| | `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. | | `useViewer` | `@pascal-app/viewer` | Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. | | `useEditor` | `apps/editor` | Editor state: active tool, structure layer visibility, panel states, editor-specific preferences. | **Access patterns:** ```typescript // Subscribe to state changes (React component) const nodes = useScene((state) => state.nodes) const levelId = useViewer((state) => state.selection.levelId) const activeTool = useEditor((state) => state.tool) // Access state outside React (callbacks, systems) const node = useScene.getState().nodes[id] useViewer.getState().setSelection({ levelId: 'level_123' }) ``` --- ## Core Concepts ### Nodes Nodes are the data primitives that describe the 3D scene. All nodes extend `BaseNode`: ```typescript BaseNode { id: string // Auto-generated with type prefix (e.g., "wall_abc123") type: string // Discriminator for type-safe handling parentId: string | null // Parent node reference visible: boolean camera?: Camera // Optional saved camera position metadata?: JSON // Arbitrary metadata (e.g., { isTransient: true }) } ``` **Node Hierarchy:** ``` Site └── Building └── Level ├── Wall → Item (doors, windows) ├── Slab ├── Ceiling → Item (lights) ├── Roof ├── Zone ├── Scan (3D reference) └── Guide (2D reference) ``` Nodes are stored in a **flat dictionary** (`Record`), not a nested tree. Parent-child relationships are defined via `parentId` and `children` arrays. --- ### Scene State (Zustand Store) The scene is managed by a Zustand store in `@pascal-app/core`: ```typescript useScene.getState() = { nodes: Record, // All nodes rootNodeIds: string[], // Top-level nodes (sites) dirtyNodes: Set, // Nodes pending system updates createNode(node, parentId), updateNode(id, updates), deleteNode(id), } ``` **Middleware:** - **Persist** - Saves to IndexedDB (excludes transient nodes) - **Temporal** (Zundo) - Undo/redo with 50-step history --- ### Scene Registry The registry maps node IDs to their Three.js objects for fast lookup: ```typescript sceneRegistry = { nodes: Map, // ID → 3D object byType: { wall: Set, item: Set, zone: Set, // ... } } ``` Renderers register their refs using the `useRegistry` hook: ```tsx const ref = useRef(null!) useRegistry(node.id, 'wall', ref) ``` This allows systems to access 3D objects directly without traversing the scene graph. --- ### Node Renderers Renderers are React components that create Three.js objects for each node type: ``` SceneRenderer └── NodeRenderer (dispatches by type) ├── BuildingRenderer ├── LevelRenderer ├── WallRenderer ├── SlabRenderer ├── ZoneRenderer ├── ItemRenderer └── ... ``` **Pattern:** 1. Renderer creates a placeholder mesh/group 2. Registers it with `useRegistry` 3. Systems update geometry based on node data Example (simplified): ```tsx const WallRenderer = ({ node }) => { const ref = useRef(null!) useRegistry(node.id, 'wall', ref) return ( {/* Replaced by WallSystem */} {node.children.map(id => )} ) } ``` --- ### Systems Systems are React components that run in the render loop (`useFrame`) to update geometry and transforms. They process **dirty nodes** marked by the store. **Core Systems (in `@pascal-app/core`):** | System | Responsibility | |--------|---------------| | `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows | | `SlabSystem` | Generates floor geometry from polygons | | `CeilingSystem` | Generates ceiling geometry | | `RoofSystem` | Generates roof geometry | | `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) | **Viewer Systems (in `@pascal-app/viewer`):** | System | Responsibility | |--------|---------------| | `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo modes) | | `ScanSystem` | Controls 3D scan visibility | | `GuideSystem` | Controls guide image visibility | **Processing Pattern:** ```typescript useFrame(() => { for (const id of dirtyNodes) { const obj = sceneRegistry.nodes.get(id) const node = useScene.getState().nodes[id] // Update geometry, transforms, etc. updateGeometry(obj, node) dirtyNodes.delete(id) } }) ``` --- ### Dirty Nodes When a node changes, it's marked as **dirty** in `useScene.getState().dirtyNodes`. Systems check this set each frame and only recompute geometry for dirty nodes. ```typescript // Automatic: createNode, updateNode, deleteNode mark nodes dirty useScene.getState().updateNode(wallId, { thickness: 0.2 }) // → wallId added to dirtyNodes // → WallSystem regenerates geometry next frame // → wallId removed from dirtyNodes ``` **Manual marking:** ```typescript useScene.getState().dirtyNodes.add(wallId) ``` --- ### Event Bus Inter-component communication uses a typed event emitter (mitt): ```typescript // Node events emitter.on('wall:click', (event) => { ... }) emitter.on('item:enter', (event) => { ... }) emitter.on('zone:context-menu', (event) => { ... }) // Grid events (background) emitter.on('grid:click', (event) => { ... }) // Event payload NodeEvent { node: AnyNode position: [x, y, z] localPosition: [x, y, z] normal?: [x, y, z] stopPropagation: () => void } ``` --- ### Spatial Grid Manager Handles collision detection and placement validation: ```typescript spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation) spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions) spatialGridManager.getSlabElevationAt(levelId, x, z) ``` Used by item placement tools to validate positions and calculate slab elevations. --- ## Editor Architecture The editor extends the viewer with: ### Tools Tools are activated via the toolbar and handle user input for specific operations: - **SelectTool** - Selection and manipulation - **WallTool** - Draw walls - **ZoneTool** - Create zones - **ItemTool** - Place furniture/fixtures - **SlabTool** - Create floor slabs ### Selection Manager The editor uses a custom selection manager with hierarchical navigation: ``` Site → Building → Level → Zone → Items ``` Each depth level has its own selection strategy for hover/click behavior. ### Editor-Specific Systems - `ZoneSystem` - Controls zone visibility based on level mode - Custom camera controls with node focusing --- ## Data Flow ``` User Action (click, drag) ↓ Tool Handler ↓ useScene.createNode() / updateNode() ↓ Node added/updated in store Node marked dirty ↓ React re-renders NodeRenderer useRegistry() registers 3D object ↓ System detects dirty node (useFrame) Updates geometry via sceneRegistry Clears dirty flag ``` --- ## Technology Stack - **React 19** + **Next.js 15** - **Three.js** (WebGPU renderer) - **React Three Fiber** + **Drei** - **Zustand** (state management) - **Zod** (schema validation) - **Zundo** (undo/redo) - **three-bvh-csg** (Boolean geometry operations) --- ## Getting Started ```bash # Install dependencies pnpm install # Run development server pnpm dev # Open http://localhost:3000 ``` --- ## Key Files | Path | Description | |------|-------------| | `packages/core/src/schema/` | Node type definitions (Zod schemas) | | `packages/core/src/hooks/use-scene.ts` | Scene state store | | `packages/core/src/hooks/scene-registry/` | 3D object registry | | `packages/core/src/systems/` | Geometry generation systems | | `packages/viewer/src/components/renderers/` | Node renderers | | `packages/viewer/src/components/viewer/` | Main Viewer component | | `apps/editor/components/tools/` | Editor tools | | `apps/editor/store/` | Editor-specific state | ================================================ FILE: apps/editor/app/api/health/route.ts ================================================ export function GET() { return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() }) } ================================================ FILE: apps/editor/app/globals.css ================================================ @import "tailwindcss"; @import "tw-animate-css"; @source "../../../packages/editor/src"; @custom-variant dark (&:is(.dark *)); @theme { --font-sans: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --font-pixel: var(--font-geist-pixel-square), var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --font-barlow: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-barlow), sans-serif; --font-mono: var(--font-geist-mono), monospace; --font-pixel: var(--font-geist-pixel-square), var(--font-geist-mono), monospace; --font-barlow: var(--font-barlow), sans-serif; --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); } :root { --radius: 0.625rem; --background: oklch(0.998 0 0); --foreground: oklch(0.145 0 0); --card: oklch(0.998 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(0.998 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); } .dark { --background: oklch(0.205 0 0); /* ~171717 */ --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch( 0.235 0 0 ); /* slightly lighter than background (0.205) but darker than previous (0.269) */ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.235 0 0); /* matching accent */ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } button, [role="button"], a { cursor: pointer; } } /* Apple-style smooth corners (squircle) — progressive enhancement */ .rounded-smooth { border-radius: var(--radius-lg); corner-shape: squircle; } .rounded-smooth-xl { border-radius: var(--radius-xl); corner-shape: squircle; } .no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } /* Loaders */ .pascal-loader-1 { width: 45px; aspect-ratio: 1; --c: no-repeat linear-gradient(currentColor 0 0); background: var(--c), var(--c), var(--c); animation: pascal-l1-1 1s infinite, pascal-l1-2 1s infinite; } @keyframes pascal-l1-1 { 0%, 100% { background-size: 20% 100%; } 33%, 66% { background-size: 20% 20%; } } @keyframes pascal-l1-2 { 0%, 33% { background-position: 0 0, 50% 50%, 100% 100%; } 66%, 100% { background-position: 100% 0, 50% 50%, 0 100%; } } .pascal-loader-2 { width: 45px; aspect-ratio: 0.75; --c: no-repeat linear-gradient(currentColor 0 0); background: var(--c) 0% 50%, var(--c) 50% 50%, var(--c) 100% 50%; background-size: 20% 50%; animation: pascal-l2 1s infinite linear; } @keyframes pascal-l2 { 20% { background-position: 0% 0%, 50% 50%, 100% 50%; } 40% { background-position: 0% 100%, 50% 0%, 100% 50%; } 60% { background-position: 0% 50%, 50% 100%, 100% 0%; } 80% { background-position: 0% 50%, 50% 50%, 100% 100%; } } .pascal-loader-3 { width: 45px; aspect-ratio: 0.75; --c: no-repeat linear-gradient(currentColor 0 0); background: var(--c) 0% 100%, var(--c) 50% 100%, var(--c) 100% 100%; background-size: 20% 65%; animation: pascal-l3 1s infinite linear; } @keyframes pascal-l3 { 16.67% { background-position: 0% 0%, 50% 100%, 100% 100%; } 33.33% { background-position: 0% 0%, 50% 0%, 100% 100%; } 50% { background-position: 0% 0%, 50% 0%, 100% 0%; } 66.67% { background-position: 0% 100%, 50% 0%, 100% 0%; } 83.33% { background-position: 0% 100%, 50% 100%, 100% 0%; } } .pascal-loader-4 { width: 45px; aspect-ratio: 1; --c: no-repeat linear-gradient(currentColor 0 0); background: var(--c), var(--c), var(--c); animation: pascal-l4-1 1s infinite, pascal-l4-2 1s infinite; } @keyframes pascal-l4-1 { 0%, 100% { background-size: 20% 100%; } 33%, 66% { background-size: 20% 40%; } } @keyframes pascal-l4-2 { 0%, 33% { background-position: 0 0, 50% 100%, 100% 100%; } 66%, 100% { background-position: 100% 0, 0 100%, 50% 100%; } } .pascal-loader-5 { width: 45px; aspect-ratio: 1; --c: no-repeat linear-gradient(currentColor 0 0); background: var(--c), var(--c), var(--c); animation: pascal-l5-1 1s infinite, pascal-l5-2 1s infinite; } @keyframes pascal-l5-1 { 0%, 100% { background-size: 20% 100%; } 33%, 66% { background-size: 20% 40%; } } @keyframes pascal-l5-2 { 0%, 33% { background-position: 0 0, 50% 100%, 100% 0; } 66%, 100% { background-position: 0 100%, 50% 0, 100% 100%; } } ================================================ FILE: apps/editor/app/layout.tsx ================================================ import { Agentation } from 'agentation' import { GeistPixelSquare } from 'geist/font/pixel' import { Barlow } from 'next/font/google' import localFont from 'next/font/local' import Script from 'next/script' import './globals.css' const geistSans = localFont({ src: './fonts/GeistVF.woff', variable: '--font-geist-sans', }) const geistMono = localFont({ src: './fonts/GeistMonoVF.woff', variable: '--font-geist-mono', }) const barlow = Barlow({ subsets: ['latin'], weight: ['400', '500', '600', '700'], variable: '--font-barlow', display: 'swap', }) export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( {process.env.NODE_ENV === 'development' && (