Unified additions/deletions diff (#96)
* Draft: WIP observer * Clean up, only one react root * Typescript 4.9.5 * Clean up * Better name * WIP: csg dependencies, error in console * WIP: working common/additions, no colors yet * Working colors * Prepare for toggle between side by side and unified * Fix position, add 5% transparency for unchanged * Clean up * Toggle to enable unified or side by side * Clean up and better material for standard view * Update src/components/diff/CadDiff.tsx Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch> * Remove irrelevant comment * Make Viewer3D take children * Introduce BaseModel for camera view stuff * Comment and clean up * Remove check needed by an old test * Add 'Experimental' Beaker icon to Unified button --------- Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
This commit is contained in:
2
.pnp.loader.mjs
generated
2
.pnp.loader.mjs
generated
@ -1946,7 +1946,7 @@ async function resolvePrivateRequest(specifier, issuer, context, nextResolve) {
|
||||
conditions: new Set(context.conditions),
|
||||
readFileSyncFn: tryReadFile
|
||||
});
|
||||
if (resolved instanceof URL) {
|
||||
if (resolved instanceof URL$1) {
|
||||
return { url: resolved.href, shortCircuit: true };
|
||||
} else {
|
||||
if (resolved.startsWith(`#`))
|
||||
|
||||
0
.yarnrc.yml
Normal file
0
.yarnrc.yml
Normal file
@ -10,6 +10,7 @@
|
||||
"@octokit/types": "^9.1.1",
|
||||
"@primer/octicons-react": "^18.3.0",
|
||||
"@primer/react": "^35.25.0",
|
||||
"@react-three/csg": "^2.2.0",
|
||||
"@react-three/drei": "^9.66.0",
|
||||
"@react-three/fiber": "^8.12.2",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
@ -30,6 +31,7 @@
|
||||
"react-scripts": "5.0.1",
|
||||
"styled-components": "^5.3.10",
|
||||
"three": "^0.151.3",
|
||||
"three-mesh-bvh": "^0.5.23",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"scripts": {
|
||||
@ -72,6 +74,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@playwright/test": "^1.32.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"eslint": "^8.39.0",
|
||||
|
||||
52
src/components/diff/BaseModel.tsx
Normal file
52
src/components/diff/BaseModel.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import type { MutableRefObject, PropsWithChildren } from 'react'
|
||||
import { Suspense, useEffect, useRef } from 'react'
|
||||
import { BufferGeometry } from 'three'
|
||||
import { Vector3 } from 'three'
|
||||
import { calculateFovFactor } from './Camera'
|
||||
|
||||
type BaseModelProps = {
|
||||
cameraRef: MutableRefObject<any>
|
||||
geometry: BufferGeometry
|
||||
}
|
||||
|
||||
export function BaseModel({
|
||||
geometry,
|
||||
cameraRef,
|
||||
children,
|
||||
}: PropsWithChildren<BaseModelProps>) {
|
||||
const groupRef = useRef<any>()
|
||||
const camera = useThree(state => state.camera)
|
||||
const canvasHeight = useThree(state => state.size.height)
|
||||
|
||||
// Camera view, adapted from KittyCAD/website
|
||||
useEffect(() => {
|
||||
if (geometry && cameraRef.current) {
|
||||
geometry.computeBoundingSphere()
|
||||
// TODO: understand the implications of this,
|
||||
// it's been disabled as it was causing before and after to be misaligned
|
||||
// geometry.center()
|
||||
|
||||
// move the camera away so the object fits in the view
|
||||
const { radius } = geometry.boundingSphere || { radius: 1 }
|
||||
if (!camera.position.length()) {
|
||||
const arbitraryNonZeroStartPosition = new Vector3(0.5, 0.5, 1)
|
||||
camera.position.copy(arbitraryNonZeroStartPosition)
|
||||
}
|
||||
const initialZoomOffset = 7.5
|
||||
camera.position.setLength(radius * initialZoomOffset)
|
||||
|
||||
// set zoom for orthographic Camera
|
||||
const fov = 15 // TODO fov shouldn't be hardcoded
|
||||
const fovFactor = calculateFovFactor(fov, canvasHeight)
|
||||
camera.zoom = fovFactor / camera.position.length()
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
}, [geometry, camera, cameraRef, canvasHeight])
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<group ref={groupRef}>{children}</group>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
57
src/components/diff/BeforeAfterModel.tsx
Normal file
57
src/components/diff/BeforeAfterModel.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useTheme } from '@primer/react'
|
||||
import { BufferGeometry } from 'three'
|
||||
import { Geometry, Base, Subtraction, Intersection } from '@react-three/csg'
|
||||
import { BaseModel } from './BaseModel'
|
||||
|
||||
type BeforeAfterModelProps = {
|
||||
beforeGeometry: BufferGeometry
|
||||
afterGeometry: BufferGeometry
|
||||
cameraRef: MutableRefObject<any>
|
||||
}
|
||||
|
||||
export function BeforeAfterModel({
|
||||
beforeGeometry,
|
||||
afterGeometry,
|
||||
cameraRef,
|
||||
}: BeforeAfterModelProps) {
|
||||
const { theme } = useTheme()
|
||||
const commonColor = theme?.colors.fg.default
|
||||
const additionsColor = theme?.colors.success.fg
|
||||
const deletionsColor = theme?.colors.danger.fg
|
||||
|
||||
return (
|
||||
// TODO: here we give beforeGeometry for auto camera centering,
|
||||
// for the lack of something better. Need to check the implications
|
||||
<BaseModel geometry={beforeGeometry} cameraRef={cameraRef}>
|
||||
{/* Unchanged */}
|
||||
<mesh>
|
||||
<meshPhongMaterial
|
||||
color={commonColor}
|
||||
transparent
|
||||
opacity={0.95}
|
||||
/>
|
||||
<Geometry>
|
||||
<Base geometry={beforeGeometry} />
|
||||
<Intersection geometry={afterGeometry} />
|
||||
</Geometry>
|
||||
</mesh>
|
||||
{/* Additions */}
|
||||
<mesh>
|
||||
<meshPhongMaterial color={additionsColor} />
|
||||
<Geometry>
|
||||
<Base geometry={afterGeometry} />
|
||||
<Subtraction geometry={beforeGeometry} />
|
||||
</Geometry>
|
||||
</mesh>
|
||||
{/* Deletions */}
|
||||
<mesh>
|
||||
<meshPhongMaterial color={deletionsColor} />
|
||||
<Geometry>
|
||||
<Base geometry={beforeGeometry} />
|
||||
<Subtraction geometry={afterGeometry} />
|
||||
</Geometry>
|
||||
</mesh>
|
||||
</BaseModel>
|
||||
)
|
||||
}
|
||||
@ -1,25 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import '@react-three/fiber'
|
||||
import { Box, useTheme, Text } from '@primer/react'
|
||||
import {
|
||||
Box,
|
||||
useTheme,
|
||||
Text,
|
||||
TabNav,
|
||||
StyledOcticon,
|
||||
} from '@primer/react'
|
||||
import { FileDiff } from '../../chrome/types'
|
||||
import { Viewer3D } from './Viewer3D'
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
|
||||
import { BufferGeometry, Mesh } from 'three'
|
||||
import { WireframeColors } from './WireframeModel'
|
||||
import { BufferAttribute, BufferGeometry, Mesh } from 'three'
|
||||
import { WireframeColors, WireframeModel } from './WireframeModel'
|
||||
import { Buffer } from 'buffer'
|
||||
import { useRef } from 'react'
|
||||
import { BeforeAfterModel } from './BeforeAfterModel'
|
||||
import { BeakerIcon } from '@primer/octicons-react'
|
||||
|
||||
function loadGeometry(file: string, checkUV = false): BufferGeometry {
|
||||
const loader = new OBJLoader()
|
||||
const buffer = Buffer.from(file, 'base64').toString()
|
||||
const group = loader.parse(buffer)
|
||||
console.log(`Model ${group.id} loaded`)
|
||||
const geometry = (group.children[0] as Mesh)?.geometry
|
||||
if (checkUV && !geometry.attributes.uv) {
|
||||
// UV is needed for @react-three/csg
|
||||
// see: github.com/KittyCAD/diff-viewer-extension/issues/73
|
||||
geometry.setAttribute(
|
||||
'uv',
|
||||
new BufferAttribute(new Float32Array([]), 1)
|
||||
)
|
||||
}
|
||||
return geometry
|
||||
}
|
||||
|
||||
function Loader3DBeforeAfter({
|
||||
before,
|
||||
after,
|
||||
}: {
|
||||
before: string
|
||||
after: string
|
||||
}) {
|
||||
const cameraRef = useRef<any>()
|
||||
const [beforeGeometry, setBeforeGeometry] = useState<BufferGeometry>()
|
||||
const [afterGeometry, setAfterGeometry] = useState<BufferGeometry>()
|
||||
useEffect(() => {
|
||||
setBeforeGeometry(loadGeometry(before, true))
|
||||
}, [before])
|
||||
useEffect(() => {
|
||||
setAfterGeometry(loadGeometry(after, true))
|
||||
}, [after])
|
||||
return beforeGeometry && afterGeometry ? (
|
||||
<Viewer3D cameraRef={cameraRef} geometry={beforeGeometry}>
|
||||
<BeforeAfterModel
|
||||
beforeGeometry={beforeGeometry}
|
||||
afterGeometry={afterGeometry}
|
||||
cameraRef={cameraRef}
|
||||
/>
|
||||
</Viewer3D>
|
||||
) : (
|
||||
<Box p={3}>
|
||||
<Text>Sorry, the rich diff can't be displayed for this file.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Loader3D({ file, colors }: { file: string; colors: WireframeColors }) {
|
||||
const cameraRef = useRef<any>()
|
||||
const [geometry, setGeometry] = useState<BufferGeometry>()
|
||||
useEffect(() => {
|
||||
const loader = new OBJLoader()
|
||||
const buffer = Buffer.from(file, 'base64').toString()
|
||||
const group = loader.parse(buffer)
|
||||
console.log(`Model ${group.id} loaded`)
|
||||
const geometry = (group.children[0] as Mesh)?.geometry
|
||||
setGeometry(geometry)
|
||||
setGeometry(loadGeometry(file))
|
||||
}, [file])
|
||||
return geometry ? (
|
||||
<Viewer3D geometry={geometry} colors={colors} />
|
||||
<Viewer3D cameraRef={cameraRef} geometry={geometry}>
|
||||
<WireframeModel
|
||||
geometry={geometry}
|
||||
cameraRef={cameraRef}
|
||||
colors={colors}
|
||||
/>
|
||||
</Viewer3D>
|
||||
) : (
|
||||
<Box p={3}>
|
||||
<Text>Sorry, the rich diff can't be displayed for this file.</Text>
|
||||
@ -28,6 +87,8 @@ function Loader3D({ file, colors }: { file: string; colors: WireframeColors }) {
|
||||
}
|
||||
|
||||
export function CadDiff({ before, after }: FileDiff): React.ReactElement {
|
||||
const canShowUnified = before && after
|
||||
let [showUnified, setShowUnified] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const beforeColors: WireframeColors = {
|
||||
face: theme?.colors.fg.default,
|
||||
@ -40,24 +101,80 @@ export function CadDiff({ before, after }: FileDiff): React.ReactElement {
|
||||
dashEdge: theme?.colors.success.subtle,
|
||||
}
|
||||
return (
|
||||
<Box display="flex" height={300} overflow="hidden" minWidth={0}>
|
||||
{before && (
|
||||
<Box flexGrow={1} minWidth={0} backgroundColor="danger.subtle">
|
||||
<Loader3D file={before} colors={beforeColors} />
|
||||
</Box>
|
||||
)}
|
||||
{after && (
|
||||
<>
|
||||
<Box display="flex" height={300} overflow="hidden" minWidth={0}>
|
||||
{canShowUnified && showUnified && (
|
||||
<Loader3DBeforeAfter before={before} after={after} />
|
||||
)}
|
||||
{!showUnified && (
|
||||
<>
|
||||
{before && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
backgroundColor="danger.subtle"
|
||||
>
|
||||
<Loader3D file={before} colors={beforeColors} />
|
||||
</Box>
|
||||
)}
|
||||
{after && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
backgroundColor="success.subtle"
|
||||
borderLeftWidth={1}
|
||||
borderLeftColor="border.default"
|
||||
borderLeftStyle="solid"
|
||||
>
|
||||
<Loader3D file={after} colors={afterColors} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{canShowUnified && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
backgroundColor="success.subtle"
|
||||
borderLeftWidth={1}
|
||||
borderLeftColor="border.default"
|
||||
borderLeftStyle="solid"
|
||||
pt={2}
|
||||
backgroundColor="canvas.default"
|
||||
borderTopWidth={1}
|
||||
borderTopColor="border.default"
|
||||
borderTopStyle="solid"
|
||||
borderBottomLeftRadius={6}
|
||||
borderBottomRightRadius={6}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Loader3D file={after} colors={afterColors} />
|
||||
<TabNav
|
||||
aria-label="Rich diff types"
|
||||
sx={{
|
||||
display: 'block',
|
||||
marginX: 'auto',
|
||||
marginBottom: '-1px',
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<TabNav.Link
|
||||
selected={!showUnified}
|
||||
onClick={() => setShowUnified(false)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Side-by-side
|
||||
</TabNav.Link>
|
||||
<TabNav.Link
|
||||
selected={showUnified}
|
||||
onClick={() => setShowUnified(true)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Unified
|
||||
<StyledOcticon
|
||||
icon={BeakerIcon}
|
||||
color="fg.muted"
|
||||
sx={{ pl: 1 }}
|
||||
aria-label="Experimental"
|
||||
/>
|
||||
</TabNav.Link>
|
||||
</TabNav>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,27 +1,23 @@
|
||||
import { useRef } from 'react'
|
||||
import { MutableRefObject, PropsWithChildren } from 'react'
|
||||
import '@react-three/fiber'
|
||||
import { BufferGeometry } from 'three'
|
||||
import { Canvas } from '@react-three/fiber'
|
||||
import { WireframeColors, WireframeModel } from './WireframeModel'
|
||||
import { Camera } from './Camera'
|
||||
import { CameraControls } from './CameraControls'
|
||||
|
||||
type Props = {
|
||||
type Viewer3DProps = {
|
||||
cameraRef: MutableRefObject<any>
|
||||
geometry: BufferGeometry
|
||||
colors: WireframeColors
|
||||
}
|
||||
|
||||
export function Viewer3D({ geometry, colors }: Props) {
|
||||
const cameraRef = useRef<any>()
|
||||
export function Viewer3D({
|
||||
cameraRef,
|
||||
geometry,
|
||||
children,
|
||||
}: PropsWithChildren<Viewer3DProps>) {
|
||||
return (
|
||||
<Canvas dpr={[1, 2]} shadows>
|
||||
{typeof window !== 'undefined' && geometry && (
|
||||
<WireframeModel
|
||||
geometry={geometry}
|
||||
cameraRef={cameraRef}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
<CameraControls cameraRef={cameraRef} />
|
||||
{geometry && <Camera geometry={geometry} />}
|
||||
</Canvas>
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, DoubleSide } from 'three'
|
||||
import { EdgesGeometry, Vector3 } from 'three'
|
||||
import { calculateFovFactor } from './Camera'
|
||||
import { EdgesGeometry } from 'three'
|
||||
import { BaseModel } from './BaseModel'
|
||||
|
||||
export type WireframeColors = {
|
||||
face: string
|
||||
@ -19,33 +18,6 @@ type Props = {
|
||||
|
||||
export function WireframeModel({ geometry, cameraRef, colors }: Props) {
|
||||
const groupRef = useRef<any>()
|
||||
const camera = useThree(state => state.camera)
|
||||
const canvasHeight = useThree(state => state.size.height)
|
||||
|
||||
// Camera view
|
||||
useEffect(() => {
|
||||
if (geometry && cameraRef.current) {
|
||||
geometry.computeBoundingSphere()
|
||||
geometry.center()
|
||||
|
||||
// move the camera away so the object fits in the view
|
||||
const { radius } = geometry.boundingSphere || { radius: 1 }
|
||||
if (!camera.position.length()) {
|
||||
const arbitraryNonZeroStartPosition = new Vector3(0.5, 0.5, 1)
|
||||
camera.position.copy(arbitraryNonZeroStartPosition)
|
||||
}
|
||||
const initialZoomOffset = 7.5
|
||||
camera.position.setLength(radius * initialZoomOffset)
|
||||
|
||||
// set zoom for orthographic Camera
|
||||
const fov = 15 // TODO fov shouldn't be hardcoded
|
||||
const fovFactor = calculateFovFactor(fov, canvasHeight)
|
||||
camera.zoom = fovFactor / camera.position.length()
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
}, [geometry, camera, cameraRef, canvasHeight])
|
||||
|
||||
// Edges for wireframe
|
||||
const edgeThresholdAngle = 10
|
||||
const edges = useMemo(
|
||||
() => new EdgesGeometry(geometry.center(), edgeThresholdAngle),
|
||||
@ -53,14 +25,14 @@ export function WireframeModel({ geometry, cameraRef, colors }: Props) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<BaseModel geometry={geometry} cameraRef={cameraRef}>
|
||||
<group ref={groupRef}>
|
||||
<mesh
|
||||
castShadow={true}
|
||||
receiveShadow={true}
|
||||
geometry={geometry}
|
||||
>
|
||||
<meshBasicMaterial
|
||||
<meshPhongMaterial
|
||||
color={colors.face}
|
||||
side={DoubleSide}
|
||||
depthTest={true}
|
||||
@ -83,6 +55,6 @@ export function WireframeModel({ geometry, cameraRef, colors }: Props) {
|
||||
<lineBasicMaterial color={colors.edge} depthTest={true} />
|
||||
</lineSegments>
|
||||
</group>
|
||||
</Suspense>
|
||||
</BaseModel>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user